Skip to content

Drivers

View raw .md
On this page

Table of Contents#


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#

dart
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#

dart
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#

dart
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.
dart
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.

dart
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#

dart
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:

  • DeeplinkServiceProvider attaches OneSignalDeeplinkHandler to this package's click stream whenever notifications is bound, so no consumer code joins them.
  • That handler reads the first of url, deep_link, link or uri it finds in the payload, which is the same key list magic-starter-laravel's OneSignalChannel writes.

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.

dart
// 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.

dart
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:

dart
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:

dart
// onesignal_factory.dart
import 'onesignal_stub.dart' if (dart.library.html) 'onesignal_web.dart';

PushDriver createOneSignalDriver() => createPlatformDriver();
  • On web: dart.library.html is available → onesignal_web.dart is imported → returns OneSignalWebDriver()
  • 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:

dart
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):

dart
@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

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.