Drivers
On this page
Table of Contents#
- Driver Concept
- PushDriver Abstract Class
- OneSignalDriver — Mobile (iOS/Android)
- OneSignalWebDriver — Web
- Conditional Imports via onesignal_factory.dart
- Creating a Custom Driver
Driver Concept#
A push driver wraps a platform-specific push SDK. The PushChannel delegates all SDK calls to whichever driver is registered with NotificationManager.setPushDriver(). Drivers expose a uniform API regardless of the underlying SDK (OneSignal, FCM, etc.), so channel logic never depends on SDK internals.
PushDriver Abstract Class#
abstract class PushDriver {
String get name;
bool get isSupported;
bool get isInitialized; // true by default in the base class
Future<PushPermissionState> permissionState();
bool get isOptedIn;
Future<void> initialize(Map<String, dynamic> config);
Future<void> login(String externalId);
Future<void> logout();
Future<String?> currentExternalId();
Future<String?> currentSubscriptionId();
Future<bool> requestPermission();
Future<void> optIn();
Future<void> optOut();
Future<void> setTags(Map<String, String> tags);
Future<void> removeTag(String key);
Stream<PushNotificationEvent> get onNotificationReceived;
Stream<PushNotificationEvent> get onNotificationClicked;
Stream<PushPermissionState> get onPermissionChanged;
Stream<PushIdentityChange> get onIdentityChanged;
Future<PushReachability> reachability(); // implemented in the base class
}
Key Members#
| Member | Description |
|---|---|
name |
Driver identifier string (e.g., 'onesignal') |
isSupported |
Synchronous platform check; PushChannel.isAvailable delegates here |
isInitialized |
Whether initialize brought the SDK up. true by default; both OneSignal drivers answer false for a build with a blank app id (the provider never starts them) and after a failed initialisation. A driver with a setup step overrides it. |
permissionState() |
Reads the current PushPermissionState. Asynchronous on both platforms: mobile reads the native permission over a platform channel, web reads the browser's own Notification.permission. |
isOptedIn |
Whether the user is actively subscribed to push |
initialize(config) |
One-time SDK init; must be called before all other methods |
login(externalId) |
Associates a user account with the push subscription |
logout() |
Removes the external user ID from the subscription |
currentExternalId() |
Reads back the external id the device is currently subscribed as, or null when not initialized or unset. Closes the loop on login/logout: without it nothing can tell whether the SDK acted on the identity it was given. |
currentSubscriptionId() |
Reads back the platform subscription id, or null when not initialized or unsubscribed. A push cannot be addressed without one, which is why reachability() consults it. |
requestPermission() |
Triggers the OS permission dialog; returns true if granted |
optIn() / optOut() |
Re-subscribe / unsubscribe without clearing the external ID |
setTags(tags) |
Segmentation tags for OneSignal targeting |
onNotificationReceived |
Broadcast stream; fires when a notification arrives while the app is in foreground |
onNotificationClicked |
Broadcast stream; fires when the user taps a notification |
onPermissionChanged |
Broadcast stream; fires on OS permission state transitions |
onIdentityChanged |
Broadcast stream of PushIdentityChange events; fires whenever the SDK itself reports the user or subscription changed underneath the app. The only trustworthy confirmation that a login/logout actually landed. |
reachability() |
Whether push can actually reach this device right now (see below). Implemented once in the base class; drivers do not override it. |
PushNotificationEvent#
class PushNotificationEvent {
final Map<String, dynamic> data;
const PushNotificationEvent(this.data);
}
The data map contains the notification's additionalData payload (mobile) or the JS event data (web).
PushIdentityChange#
class PushIdentityChange {
final String? externalId;
final String? subscriptionId;
final bool? optedIn;
const PushIdentityChange({this.externalId, this.subscriptionId, this.optedIn});
}
Every field is nullable because a single SDK event reports only part of the state: a user change carries the external id, a subscription change carries the subscription id and the opt-in flag. A null field means "this event did not report it", never "it is empty".
Reachability#
PushDriver.reachability() derives a four-state answer instead of each driver
declaring one, so every driver answers the same way:
| State | Meaning |
|---|---|
PushReachability.unavailable |
No platform driver here at all (isSupported == false), or one whose SDK never started (isInitialized == false), so a request would only be refused. |
PushReachability.blocked |
Permission was denied and the platform will not re-prompt. The app has to point the person at the OS/browser setting; calling requestPermission() again silently no-ops. |
PushReachability.off |
Never asked, or asked and not opted in. Safe to call requestPermission(). |
PushReachability.on |
Permitted, opted in, and holding a subscription id — the only state a push can actually reach. |
final reachability = await driver.reachability();
This is the read a soft-prompt UI should gate on before showing itself, and the read that answers "can I send this device a push" without ever raising the OS dialog.
OneSignalDriver — Mobile (iOS/Android)#
OneSignalDriver wraps the onesignal_flutter package.
class OneSignalDriver extends PushDriver {
@override
String get name => 'onesignal';
@override
bool get isSupported => Platform.isIOS || Platform.isAndroid;
@override
Future<PushPermissionState> permissionState() async {
if (!_initialized) return PushPermissionState.notDetermined;
final native = await OneSignal.Notifications.permissionNative();
return switch (native) {
OSNotificationPermission.authorized => PushPermissionState.authorized,
OSNotificationPermission.provisional ||
OSNotificationPermission.ephemeral =>
PushPermissionState.provisional,
OSNotificationPermission.notDetermined =>
PushPermissionState.notDetermined,
// Only iOS reports a real tri-state; elsewhere permissionNative
// collapses onto denied, so ask the SDK whether a prompt would still
// show. It does only when the device has never been asked.
OSNotificationPermission.denied =>
await OneSignal.Notifications.canRequest()
? PushPermissionState.notDetermined
: PushPermissionState.denied,
};
}
@override
Future<String?> currentExternalId() async =>
_initialized ? OneSignal.User.getExternalId() : null;
@override
Future<String?> currentSubscriptionId() async =>
_initialized ? OneSignal.User.pushSubscription.id : null;
@override
Future<void> initialize(Map<String, dynamic> config) async {
final appId = config['app_id'] as String?;
OneSignal.initialize(appId!);
OneSignal.Notifications.addForegroundWillDisplayListener((event) {
_receivedController.add(
PushNotificationEvent(event.notification.additionalData ?? {}),
);
});
OneSignal.Notifications.addClickListener((event) {
_clickedController.add(
PushNotificationEvent(event.notification.additionalData ?? {}),
);
});
// The observer carries a bare bool; re-read permissionState() instead of
// collapsing the tri-state onto denied.
OneSignal.Notifications.addPermissionObserver((permission) {
unawaited(permissionState().then(_permissionController.add));
});
OneSignal.User.addObserver((state) {
_identityController.add(
PushIdentityChange(externalId: state.current.externalId),
);
});
OneSignal.User.pushSubscription.addObserver((state) {
_identityController.add(
PushIdentityChange(
subscriptionId: state.current.id,
optedIn: state.current.optedIn,
),
);
});
_initialized = true;
}
@override
Future<void> login(String externalId) => OneSignal.login(externalId);
@override
Future<void> logout() => OneSignal.logout();
@override
Future<bool> requestPermission() =>
OneSignal.Notifications.requestPermission(true);
@override
Future<void> optIn() => OneSignal.User.pushSubscription.optIn();
@override
Future<void> optOut() => OneSignal.User.pushSubscription.optOut();
@override
Future<void> setTags(Map<String, String> tags) =>
OneSignal.User.addTags(tags);
@override
Stream<PushIdentityChange> get onIdentityChanged =>
_identityController.stream;
}
Listening to Events#
final driver = NotificationManager().pushDriver as OneSignalDriver;
driver.onNotificationReceived.listen((event) {
final title = event.data['title'];
// show in-app snackbar
});
driver.onNotificationClicked.listen((event) {
// `event.data` is the notification's `additionalData`, so a destination is
// only here when the backend put it there. Four keys, first match wins,
// because that is what `magic_deeplink`'s own handler looks for and the
// backend conventions in this ecosystem are not uniform about which one.
final url = event.data['url'] ??
event.data['deep_link'] ??
event.data['link'] ??
event.data['uri'];
// Guarded, because an absent key is the ordinary case rather than the
// exceptional one: a notification with nowhere to go is a valid
// notification.
if (url is String && url.isNotEmpty) MagicRoute.to(url);
});
Opening a screen from a tap#
The snippet above is the whole of what this package does about a tap: it hands you the payload and stops. Nothing here routes anywhere, and a tapped push that opens the app on its own initial route rather than on the notification's screen is this package working as built.
The ecosystem's answer is magic_deeplink, and the wiring between the two is
already done in both directions:
DeeplinkServiceProviderattachesOneSignalDeeplinkHandlerto this package's click stream whenevernotificationsis bound, so no consumer code joins them.- That handler reads the first of
url,deep_link,linkoruriit finds in the payload, which is the same key listmagic-starter-laravel'sOneSignalChannelwrites.
What is NOT done for you is the last link: an app has to register a handler
of its own, because magic_deeplink publishes no handler stubs at install
time. Without one, DeeplinkManager.handleUri walks an empty list, returns
false, and nothing is logged. A pipeline that is correct at every step and
does nothing at the end looks exactly like a notification that was never
tapped.
// In your AppServiceProvider.boot().
app.make<DeeplinkManager>('deeplinks').registerHandler(
RouteDeeplinkHandler(paths: const ['/notifications', '/orders/:id']),
);
RouteDeeplinkHandler navigates to the path the link names via
MagicRoute.to, so the paths you list are your own in-app routes. Handlers
form a first-match chain in registration order; write your own
DeeplinkHandler when a tap has to do something other than navigate, which is
also the one that gets to read DeeplinkSource and the payload.
See magic_deeplink's own documentation for the platform association files
(apple-app-site-association, assetlinks.json) a Universal or App Link needs
before any of this is reached.
OneSignalWebDriver — Web#
OneSignalWebDriver communicates with the OneSignal Web SDK v16 via JavaScript interop through OneSignalJsInterop.
class OneSignalWebDriver extends PushDriver {
@override
String get name => 'onesignal';
@override
bool get isSupported => kIsWeb;
@override
Future<void> initialize(Map<String, dynamic> config) async {
await OneSignalJsInterop.init(
appId: config['app_id'],
safariWebId: config['safari_web_id'],
notifyButtonEnabled: config['notify_button_enabled'] ?? false,
);
}
@override
Future<void> login(String externalId) => OneSignalJsInterop.login(externalId);
@override
Future<bool> requestPermission() => OneSignalJsInterop.requestPermission();
}
The web driver also exposes additional getters that surface JS interop values:
final webDriver = NotificationManager().pushDriver as OneSignalWebDriver;
print(webDriver.subscriptionId); // OneSignal subscription UUID
print(webDriver.externalId); // currently logged-in external user ID
print(webDriver.oneSignalId); // OneSignal internal user ID
Conditional Imports via onesignal_factory.dart#
The service provider never imports OneSignalDriver or OneSignalWebDriver directly. It calls createOneSignalDriver() from onesignal_factory.dart, which uses Dart's conditional import system:
// onesignal_factory.dart
import 'onesignal_stub.dart' if (dart.library.html) 'onesignal_web.dart';
PushDriver createOneSignalDriver() => createPlatformDriver();
- On web:
dart.library.htmlis available →onesignal_web.dartis imported → returnsOneSignalWebDriver() - On mobile: the stub is imported → returns
OneSignalDriver()
This means the entire onesignal_flutter dependency (which uses dart:io) is tree-shaken out of the web build, and the JS interop code is tree-shaken out of the mobile build.
Creating a Custom Driver#
To add FCM or another push service, extend PushDriver:
import 'package:magic_notifications/magic_notifications.dart';
class FcmDriver extends PushDriver {
final StreamController<PushNotificationEvent> _receivedController =
StreamController<PushNotificationEvent>.broadcast();
final StreamController<PushNotificationEvent> _clickedController =
StreamController<PushNotificationEvent>.broadcast();
final StreamController<PushPermissionState> _permissionController =
StreamController<PushPermissionState>.broadcast();
final StreamController<PushIdentityChange> _identityController =
StreamController<PushIdentityChange>.broadcast();
@override
String get name => 'fcm';
@override
bool get isSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
@override
Future<PushPermissionState> permissionState() async =>
PushPermissionState.notDetermined;
@override
bool get isOptedIn => false;
@override
Future<void> initialize(Map<String, dynamic> config) async {
// FirebaseMessaging.instance.getToken(); etc.
}
@override
Future<void> login(String externalId) async { /* store token */ }
@override
Future<void> logout() async { /* remove token */ }
@override
Future<String?> currentExternalId() async => null;
@override
Future<String?> currentSubscriptionId() async => null;
@override
Future<bool> requestPermission() async {
// NotificationSettings settings = await FirebaseMessaging.instance.requestPermission();
return false;
}
@override
Future<void> optIn() async {}
@override
Future<void> optOut() async {}
@override
Future<void> setTags(Map<String, String> tags) async {}
@override
Future<void> removeTag(String key) async {}
@override
Stream<PushNotificationEvent> get onNotificationReceived =>
_receivedController.stream;
@override
Stream<PushNotificationEvent> get onNotificationClicked =>
_clickedController.stream;
@override
Stream<PushPermissionState> get onPermissionChanged =>
_permissionController.stream;
@override
Stream<PushIdentityChange> get onIdentityChanged =>
_identityController.stream;
// reachability() is inherited from the base class; no override needed.
}
Register it with the manager's driver registry in your service provider's
boot() (see Notification Manager
for the resolution order):
@override
Future<void> boot() async {
final manager = NotificationManager();
manager.extend('fcm', () => FcmDriver());
await manager.pushDriver.initialize(
{'project_id': Config.get('notifications.push.project_id')},
);
}
Related