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;
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 |
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). |
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) {
final url = event.data['action_url'];
MagicRoute.to(url);
});
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