> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/onesignal-push-ios-notification-service-extension.md).

# OneSignal Push and iOS NSE

How I organize OneSignal around app and user lifecycles, permission handling, click routing, and an iOS Notification Service Extension in Flutter

## Result

In my app, OneSignal is not confined to one screen. The SDK is initialized near the composition root, permission is requested from the UI, the user is identified after authentication completes, and notification clicks are sent to the shared router. On iOS, a separate Notification Service Extension processes rich notifications before their content is displayed.

The overall flow looks like this:

```
Flutter host app
  │
  ├── App start ───────► Initialize OneSignal once
  ├── User action ─────► Request notification permission
  ├── Auth success ────► Attach subscription to opaque user key
  ├── Logout ──────────► Return subscription to anonymous user
  └── Push clicked ────► Link candidate ─► Deep-link coordinator

iOS before the notification is displayed
  │
  └── Notification Service Extension
        ├── Process rich content and receipt
        └── Time expires ─► Return best-attempt content
```

The most important point is that each concern has one owner:

* An app-level coordinator owns initialization and SDK listeners.
* Permission state reflects the operating system result, not merely the fact that the app called a prompt method.
* The auth lifecycle owns OneSignal `login()` and `logout()`.
* The deep-link coordinator owns validation and navigation.
* The iOS Notification Service Extension runs independently of Flutter state.

This article applies to Android and iOS. The Dart layer is shared. Android adds runtime notification permission and launch URL policy, while iOS adds an App Group, an extension target, and a native processing deadline.

The reference source already has a manager, permission flow, user binding, click handler, and iOS extension. It also reveals three gaps worth hardening when implementing the pattern again: the permission result is not persisted with its real meaning, OneSignal logout does not run through every auth logout path, and the click listener is not removed when its widget owner is disposed. The coordinator examples below are improvements derived from that evidence, not a claim that the source already has complete test coverage for those three changes.

## Problem

A minimal push integration needs only a few lines:

```dart
OneSignal.initialize(appId);
await OneSignal.Notifications.requestPermission(false);
OneSignal.Notifications.addClickListener(handleClick);
```

That is enough to start, but it does not answer the questions that appear when an app has sessions and complex navigation:

* Was the SDK initialized before permission, login, or listeners were used?
* Did the user grant permission, deny it, or did the app merely call the request method?
* When the user changes permission in Settings, does in-app state update?
* Is the subscription anonymous, or is it attached to a known user?
* Does a session-expiry logout clear OneSignal identity like the menu logout button does?
* Does remounting a widget add a second listener?
* Can a notification click and an initial deep link navigate to the same destination twice?
* Does the iOS extension use the correct App Group and still return content when its deadline approaches?

### Push has four independent states

I separate push into four states instead of one `enabled` flag:

| State                | Question                                                            | Owner                        |
| -------------------- | ------------------------------------------------------------------- | ---------------------------- |
| SDK readiness        | Has the adapter sent initialize and attached listeners?             | App-level coordinator        |
| OS permission        | Does the operating system currently allow notifications?            | Permission coordinator/store |
| User identity        | Is the subscription anonymous or attached to an authenticated user? | Auth lifecycle               |
| Navigation readiness | Can the router process a link candidate yet?                        | Deep-link coordinator        |

These states do not transition at the same time. The SDK can initialize before the user signs in. A user can be signed in without granting notification permission. A notification click can launch the app before the router has finished building.

If one widget owns all four states, the widget lifecycle incorrectly becomes the SDK and user-session lifecycle.

### “Permission requested” does not mean “permission granted”

In the reference source, the Home screen reads a persisted flag so permission is requested only once. After calling the manager, the reducer changes that flag to `true`.

The problem is that `requestPermission()` in `onesignal_flutter 5.3.3` returns `Future<bool>`, but that result is not used. The manager also has a build policy that skips the prompt. If the UI persists the flag immediately after the call, the app can record “requested” even when:

* The prompt was skipped by build policy.
* The native call threw an exception.
* The user denied permission.
* Permission was changed later in Settings.

Prompt history is still useful, but it is not a replacement for current permission state.

### SDK listeners are global, widget lifecycles are local

The exact `5.3.3` SDK stores click callbacks in a list and provides both `addClickListener()` and `removeClickListener()`.

The source adds a listener in a tab widget's `initState()`, but `dispose()` releases only its `TabController`. If the owner is mounted again, the old callback can remain in the SDK:

```
Widget mount #1 ─► add listener A
Widget dispose   ─► listener A remains
Widget mount #2 ─► add listener B
Push clicked     ─► A + B both handle it
```

The consequence is more than two identical log lines. The app can record analytics twice, reset state twice, or push the same route twice.

### Logout belongs to auth lifecycle, not a button

The source calls OneSignal logout from the menu logout button. The same app also has logout paths for session expiry, account switching, and other screens.

If cleanup exists only in the button, auth state can already be anonymous while the OneSignal subscription remains temporarily attached to the previous user. The manager handles a changed External ID when another user signs in later, but the interval between sessions still needs a clear policy.

### The iOS extension does not run in the Flutter isolate

A Notification Service Extension is a separate bundle embedded in the app. iOS runs it only for eligible remote notifications and gives it a short period to modify the content.

The extension does not share memory with the Flutter host app. It cannot read Redux state, obtain a `BuildContext`, or wait for the app to open. Every dependency and fallback therefore has to be configured in the native target.

## Solution

### Put OneSignal behind a small gateway

The source pins the dependency as follows:

```yaml
dependencies:
  onesignal_flutter: 5.3.3
```

I do not let widgets depend directly on `OSNotificationClickEvent`. The gateway converts the SDK event into a neutral model:

```dart
typedef PushClickListener = void Function(PushClick event);
typedef PushPermissionObserver = void Function(bool granted);

final class PushClick {
  const PushClick({
    required this.messageKey,
    required this.launchUrl,
  });

  final String messageKey;
  final String? launchUrl;
}

abstract interface class PushGateway {
  Future<void> initialize();

  bool get hasPermission;
  Future<bool> canRequestPermission();
  Future<bool> requestPermission({required bool fallbackToSettings});
  void addPermissionObserver(PushPermissionObserver observer);
  void removePermissionObserver(PushPermissionObserver observer);

  Future<void> identify(String opaqueUserKey);
  Future<void> clearIdentity();

  void addClickListener(PushClickListener listener);
  void removeClickListener(PushClickListener listener);
}
```

The App ID enters the concrete gateway through runtime configuration. It does not appear in widgets, analytics, or documentation.

The adapter keeps a mapping between neutral callbacks and native callbacks so it can remove the exact reference that was added:

```dart
import 'package:flutter/foundation.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';

final class OneSignalPushGateway implements PushGateway {
  OneSignalPushGateway(this.appId);

  final String appId;
  bool _initialized = false;

  final Map<PushClickListener, OnNotificationClickListener> _clickListeners =
      <PushClickListener, OnNotificationClickListener>{};

  @override
  Future<void> initialize() async {
    if (_initialized) return;

    if (kDebugMode) {
      await OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
    }

    OneSignal.initialize(appId);
    _initialized = true;
  }

  @override
  bool get hasPermission => OneSignal.Notifications.permission;

  @override
  Future<bool> canRequestPermission() {
    return OneSignal.Notifications.canRequest();
  }

  @override
  Future<bool> requestPermission({required bool fallbackToSettings}) {
    return OneSignal.Notifications.requestPermission(fallbackToSettings);
  }

  @override
  void addPermissionObserver(PushPermissionObserver observer) {
    OneSignal.Notifications.addPermissionObserver(observer);
  }

  @override
  void removePermissionObserver(PushPermissionObserver observer) {
    OneSignal.Notifications.removePermissionObserver(observer);
  }

  @override
  Future<void> identify(String opaqueUserKey) {
    return OneSignal.login(opaqueUserKey);
  }

  @override
  Future<void> clearIdentity() {
    return OneSignal.logout();
  }

  @override
  void addClickListener(PushClickListener listener) {
    if (_clickListeners.containsKey(listener)) return;

    void nativeListener(OSNotificationClickEvent event) {
      listener(
        PushClick(
          messageKey: event.notification.notificationId,
          launchUrl: event.notification.launchUrl,
        ),
      );
    }

    _clickListeners[listener] = nativeListener;
    OneSignal.Notifications.addClickListener(nativeListener);
  }

  @override
  void removeClickListener(PushClickListener listener) {
    final nativeListener = _clickListeners.remove(listener);
    if (nativeListener == null) return;

    OneSignal.Notifications.removeClickListener(nativeListener);
  }
}
```

The gateway does not catch and discard every exception. The coordinator above it decides which failures only need sanitized telemetry, which ones should be retried, and which ones may affect the UI.

### Await async work before initialize

`OneSignal.initialize()` in the exact SDK returns `void`, but the source performs an asynchronous call to enable verbose logging before initialization in debug builds.

If the composition root calls the gateway without `await`, a debug build can continue to `runApp()` before `initialize()` executes:

```dart
Future<void> bootstrap() async {
  await pushCoordinator.start();
  runApp(const App());
}
```

This `await` does not mean a native push token is already available. It only guarantees that the steps the adapter defines before initialization are complete and listener ownership is established in the intended order.

Verbose logging is enabled only in debug. I do not send raw SDK logs to analytics or public issues because those logs can contain subscription state and payload data.

### Keep init and listeners in one coordinator

The coordinator lives near the app shell and has an idempotent `start()` method:

```dart
abstract interface class DeepLinkIngress {
  void addPushCandidate({
    required String messageKey,
    required String uri,
  });
}

final class PushCoordinator {
  PushCoordinator({
    required this.gateway,
    required this.deepLinks,
    required this.onPermissionChanged,
  });

  final PushGateway gateway;
  final DeepLinkIngress deepLinks;
  final PushPermissionObserver onPermissionChanged;

  bool _started = false;

  late final PushClickListener _clickListener = _onPushClick;

  Future<void> start() async {
    if (_started) return;

    await gateway.initialize();
    gateway.addPermissionObserver(onPermissionChanged);
    gateway.addClickListener(_clickListener);
    _started = true;
  }

  void dispose() {
    if (!_started) return;

    gateway.removeClickListener(_clickListener);
    gateway.removePermissionObserver(onPermissionChanged);
    _started = false;
  }

  void _onPushClick(PushClick event) {
    final uri = event.launchUrl?.trim() ?? '';
    if (uri.isEmpty) return;

    deepLinks.addPushCandidate(
      messageKey: event.messageKey,
      uri: uri,
    );
  }
}
```

The callback reference is easy to miss. This example uses `late final` so `removeClickListener()` receives the same function object that was added. Creating a new closure inside `dispose()` does not remove the old listener.

The MQTT article below also uses an app-level owner to separate a connection and listener lifecycle from widget lifecycle. Push does not use MQTT, but the ownership boundary is the same.

{% content-ref url="/pages/3Eh62eld4JjEvdiZuUZE" %}
[MQTT and App Lifecycle](/flutter/my-flutter/systems-realtime/mqtt-realtime-app-lifecycle.md)
{% endcontent-ref %}

### Model permission from the native result

In the public example, I use meaningful state instead of an `isRequested` boolean:

```dart
enum PushPermissionState {
  unknown,
  canAsk,
  granted,
  denied,
  needsSettings,
}
```

A permission sync can be reduced to this:

```dart
Future<PushPermissionState> readPermission(PushGateway gateway) async {
  if (gateway.hasPermission) {
    return PushPermissionState.granted;
  }

  final canAsk = await gateway.canRequestPermission();
  return canAsk
      ? PushPermissionState.canAsk
      : PushPermissionState.needsSettings;
}
```

In the proposed flow, the native prompt runs only after the user taps an action to allow notifications. The outcome is persisted after the Future completes:

```dart
Future<PushPermissionState> requestFromUserAction(
  PushGateway gateway,
) async {
  final granted = await gateway.requestPermission(
    fallbackToSettings: false,
  );

  return granted
      ? PushPermissionState.granted
      : PushPermissionState.denied;
}
```

The permission observer updates state when the user changes permission in Settings. `askedAtLeastOnce` can still be stored to control a soft prompt, but it must not become the source of truth for permission.

This is the UI flow I recommend:

```
Read native permission
       │
       ├── granted ───────► Do not prompt
       ├── canAsk ────────► Explain the value in the app
       │                         │ user agrees
       │                         ▼
       │                   Native prompt
       └── needsSettings ──► Deliberate CTA to open Settings
```

OneSignal recommends a soft prompt before the native dialog. On iOS, the regular native prompt normally provides only one opportunity. On Android 13 and later, notifications require runtime permission. I therefore do not request permission as soon as the app opens if the user has not yet seen why the notifications are valuable.

`fallbackToSettings: true` can send the user to Settings after the native prompt is no longer available. That is a UX decision triggered by a clear user action, not a side effect that should run silently during bootstrap.

### Move identify and clear identity into auth lifecycle

OneSignal `login(externalId)` attaches the current mobile subscription to a known user. `logout()` removes the External ID from this device's subscription and moves the SDK to a new anonymous user.

I call identify only after authentication or session restore provides an opaque stable key:

```dart
final class PushIdentityCoordinator {
  PushIdentityCoordinator(this.gateway);

  final PushGateway gateway;

  Future<void> onAuthenticated(String opaqueUserKey) {
    return gateway.identify(opaqueUserKey);
  }

  Future<void> onLoggedOut() {
    return gateway.clearIdentity();
  }
}
```

`opaqueUserKey` is not an email address, phone number, or display value. The public example uses only a fake value such as `user_123`.

More importantly, `onLoggedOut()` runs from a centralized auth middleware or use case:

```
Manual logout ───────┐
Session expired ─────┼──► Auth logout owner ─► clear push identity
Switch account ──────┤                         ├► close socket/session
Account unavailable ─┘                         └► reset navigation
```

The button only emits a logout intent. It does not clean up each SDK by itself.

Messaging cleanup should be best effort and must not keep the user inside a session that has already logged out indefinitely. In the proposed hardening, the auth owner awaits the operation for a bounded period to reduce the window where the subscription remains attached to the previous user. It then records a sanitized failure category if the provider does not respond.

Do not log the opaque key, External ID, OneSignal ID, push token, or subscription ID.

### Hand clicks off to the deep-link coordinator

A notification click tells the app that the user opened a push. The callback does not need to create a second router.

The Android source disables OneSignal's automatic launch URL behavior:

```xml
<application>
    <meta-data
        android:name="com.onesignal.suppressLaunchURLs"
        android:value="true" />
</application>
```

Flutter therefore receives the click, ignores an empty URL, and sends the rest as a link candidate. The deep-link coordinator remains responsible for:

* Parsing the `Uri` once.
* Allowlisting scheme, host, path, and parameters.
* Deduplicating against App Links or other providers.
* Waiting until the navigator and auth state are ready.
* Converting the candidate into a typed navigation intent.
* Enforcing authorization at the destination.

This article stops at the handoff contract because the deep-link pipeline is a separate topic. The boundary to keep is that the OneSignal adapter does not open a raw URL in the browser and does not treat a URL as an authorization token.

{% content-ref url="/pages/XuzrnRXJWWpiiB1XhH1m" %}
[Multi-Source Deep Links in Flutter](/flutter/my-flutter/systems-realtime/deep-link-app-links-adjust-onesignal.md)
{% endcontent-ref %}

The click listener runs after the app has launched, so the callback does not call another “open app” API. For the terminated state, the proposed flow queues the candidate in an app-level owner instead of retaining a widget `BuildContext`.

### Choose a foreground notification policy

The source does not register a foreground lifecycle listener, so it keeps the SDK's default display behavior. That is a valid choice when the app wants foreground notifications to appear like ordinary system notifications.

If custom behavior is required, the listener should still belong to `PushCoordinator`:

* Call `preventDefault()` when the app needs to delay or suppress display.
* Display manually at most once.
* Remove the listener when the coordinator ends.
* Do not let the SDK auto-display while also creating a second notification manually.

I do not add a custom Android notification service extension because the source does not have that branch.

### Configure Android

Android 13/API 33 and later require runtime `POST_NOTIFICATIONS` permission for non-exempt notifications. The source targets a newer API, so this permission flow must be verified on a suitable device or emulator.

The OneSignal Flutter plugin brings in a native manifest. Verification must therefore inspect the build variant's **merged manifest** instead of drawing conclusions only from `android/app/src/main/AndroidManifest.xml`.

Verify that:

* The merged manifest contains `POST_NOTIFICATIONS` when the target API requires it.
* Notification icons and accent colors use public app resources.
* `suppressLaunchURLs` matches the decision for Flutter to handle clicks.
* Two services do not both display the same push.
* Debug, staging, and production builds load the correct App ID from runtime configuration without logging it.

I do not include `google-services.json`, an App ID, or a real merged manifest in this article.

### Configure the iOS host app

The iOS host app needs the relevant capabilities:

* Push Notifications.
* Background Modes → Remote notifications when the flow uses background delivery.
* App Groups to share data with the Notification Service Extension.

The Runner and extension must use the exact same App Group for each build variant. With the default convention, the group has a neutral form:

```
group.com.example.app.onesignal
```

When an app uses a custom App Group, OneSignal documentation requires `OneSignal_app_groups_key` in the `Info.plist` of **both the host app and the extension**.

The source also keeps badge ownership inside the app by preventing OneSignal from clearing the badge automatically when the app opens:

```xml
<key>OneSignal_disable_badge_clearing</key>
<true/>
```

I do not copy this setting by habit. If the app has no dedicated badge coordinator, the badge may not reset as users expect. The rule to preserve is that one owner updates the badge and the policy is tested in foreground, background, and after opening a notification.

Every Runner entitlement I reviewed in the reference source contains one App Group, and each corresponding extension entitlement also contains one App Group. I compared the values by hash to verify their build-variant families without exposing the real identifiers.

The Flavor article explains how to separate bundle, signing, and native configuration by environment. This article keeps only the requirement that OneSignal and the extension select the same variant.

{% content-ref url="/pages/1lpIWX1RW20PkI7TGvxs" %}
[Flavor](/flutter/my-flutter/architecture-state/flavor.md)
{% endcontent-ref %}

### Add the iOS Notification Service Extension

The source uses CocoaPods with a separate target:

```ruby
platform :ios, '15.0'

target 'Runner' do
  use_frameworks!
  use_modular_headers!
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

target 'OneSignalNotificationServiceExtension' do
  use_frameworks!
  pod 'OneSignalXCFramework', '>= 5.0.0', '< 6.0'
end
```

The verified snapshot's `Podfile.lock` resolves the native framework to `5.2.13`. A Podfile range does not replace lockfile review. Running `pod update` can change the native version even when the Dart package remains unchanged.

The extension `Info.plist` needs two main keys:

```xml
<key>NSExtension</key>
<dict>
    <key>NSExtensionPointIdentifier</key>
    <string>com.apple.usernotifications.service</string>
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
```

The extension target must be embedded in the host app. The deployment target of the host, extension, and Pod policy should match. In Xcode Build Phases, `Copy only when installing` must not be enabled for the embedded extension.

### Hand native content to OneSignalExtension

The extension makes a mutable copy and hands the request to `OneSignalExtension`:

```swift
import UserNotifications
import OneSignalExtension

final class NotificationService: UNNotificationServiceExtension {
    private var handler: ((UNNotificationContent) -> Void)?
    private var request: UNNotificationRequest?
    private var bestAttempt: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.request = request
        self.handler = contentHandler
        self.bestAttempt = request.content.mutableCopy()
            as? UNMutableNotificationContent

        guard let bestAttempt else {
            contentHandler(request.content)
            return
        }

        OneSignalExtension.didReceiveNotificationExtensionRequest(
            request,
            with: bestAttempt,
            withContentHandler: contentHandler
        )
    }

    override func serviceExtensionTimeWillExpire() {
        guard
            let request,
            let handler,
            let bestAttempt
        else { return }

        OneSignalExtension.serviceExtensionTimeWillExpireRequest(
            request,
            with: bestAttempt
        )
        handler(bestAttempt)
    }
}
```

iOS runs the Notification Service Extension only for an eligible remote notification. Apple requires an alert payload and `mutable-content: 1`. OneSignal sets this flag automatically for some rich notifications, including attachments and action buttons.

Apple gives the extension about 30 seconds to modify the content. I still design it to finish much earlier and always retain `serviceExtensionTimeWillExpire()` as the best-attempt return path.

The extension does not import Flutter, read the global store, or call a long-running business API. It also does not log the title, body, attachment URL, or full `userInfo`.

### Record only neutral telemetry

Push crosses several SDK and process boundaries, so it needs observability. The raw payload is not safe log data.

| Stage      | Safe to record                                        | Do not record                                  |
| ---------- | ----------------------------------------------------- | ---------------------------------------------- |
| Init       | success/failure category, latency bucket, build class | App ID, push token, raw SDK log                |
| Permission | previous state, prompt shown, result                  | User ID, device token                          |
| Identity   | `identify/clear` operation, outcome                   | External ID, email, phone                      |
| Click      | handled/fallback/ignored, route category, readiness   | title, body, raw URL, full notification object |
| iOS NSE    | started/completed/timeout, duration bucket            | payload, media URL, App Group ID               |

The source currently has click analytics that use some metadata from the notification and session. The public example does not copy that schema. Every field should be reviewed for consent, retention, and access control before entering analytics.

The Remote Config article below applies the same principle: keep the vendor SDK behind an adapter and record only sanitized telemetry.

{% content-ref url="/pages/VlHUWEnrJDCc9sRZw2rf" %}
[Firebase Remote Config and feature flags](/flutter/my-flutter/security-observability/firebase-remote-config-feature-flags.md)
{% endcontent-ref %}

### Verify the result

The source has no direct unit, widget, or integration tests for the OneSignal manager, permission flow, identity binding, click listener, or iOS extension. Static evidence therefore proves only that code and configuration exist. It does not prove end-to-end delivery.

With the gateway and coordinator in the public example, the unit-test suite should keep these cases:

```dart
test('starting twice adds only one click listener', () async {
  final gateway = FakePushGateway();
  final coordinator = PushCoordinator(
    gateway: gateway,
    deepLinks: FakeDeepLinkIngress(),
    onPermissionChanged: (_) {},
  );

  await coordinator.start();
  await coordinator.start();

  expect(gateway.initializeCount, 1);
  expect(gateway.clickListenerCount, 1);
  expect(gateway.permissionObserverCount, 1);
});

test('dispose removes the listener that was added', () async {
  final gateway = FakePushGateway();
  final coordinator = PushCoordinator(
    gateway: gateway,
    deepLinks: FakeDeepLinkIngress(),
    onPermissionChanged: (_) {},
  );

  await coordinator.start();
  coordinator.dispose();

  expect(gateway.clickListenerCount, 0);
  expect(gateway.permissionObserverCount, 0);
});
```

Permission tests should cover:

* `hasPermission=true` returns `granted` without prompting.
* `canRequest=true` returns `canAsk`.
* Grant and denial outcomes are persisted from the native result.
* A build-policy skip does not become “permission requested.”
* An exception does not write a successful state.
* The permission observer updates state after a Settings change.

Identity tests should cover:

* Session restore identifies once with an opaque key.
* Signing in again as the same user does not create unnecessary side effects.
* Account switching clears or switches identity in the intended order.
* Manual logout, session expiry, and unavailable-account flows all use centralized cleanup.
* A provider failure does not keep auth logout blocked indefinitely and records only a sanitized failure category.

Click tests should cover an empty URL, a valid candidate, repeated listener add/remove cycles, and a single candidate handoff. Exact allowlisting, multi-provider deduplication, and auth/readiness gates belong to the deep-link coordinator test suite.

### Device test matrix

| Platform/state | Case                                        | Expected result                                      |
| -------------- | ------------------------------------------- | ---------------------------------------------------- |
| Android 13+    | First prompt grant/deny                     | State matches the native result                      |
| Android        | Foreground/background/terminated            | One notification, one click action                   |
| Android        | Automatic launch is suppressed              | Only the Flutter router navigates                    |
| iOS            | First prompt, deny, then change in Settings | Permission observer synchronizes state               |
| iOS            | Rich notification with attachment/action    | Extension runs and content is displayed              |
| iOS            | Extension approaches timeout                | Best-attempt content is still returned               |
| Both           | Login → logout → switch account             | Subscription does not remain on the old user context |
| Both           | Push click before router readiness          | Candidate is retained and handled once               |

OneSignal notes a Flutter debug-build limitation when the app is force-closed and the user taps a notification. Test killed-state clicks with a release build on a physical device.

I do not use dashboard screenshots or raw device-console output as evidence in this article because they can contain identifiers and payload data. A review result should record pass/fail counts together with build class, platform, and package version.

### Common mistakes and trade-offs

#### Persisting `isRequested=true` immediately after requesting permission

The symptom is that the app does not ask again, but state still cannot say whether the user granted or denied permission. Persist the outcome after the Future completes, and keep native permission plus its observer as the source of truth.

#### Adding a listener in `initState()` without removing it

The symptom is one click producing multiple analytics events or navigation actions. Check the widget lifetime and callback count in the fake gateway. Move ownership to the app-level coordinator and remove the exact callback reference.

#### Clearing OneSignal only from the logout button

The symptom is a timeout-driven logout that does not reset messaging identity. Move cleanup into the auth logout owner so every entry point is covered.

#### Mismatched App Groups between host and extension

The app can still build while rich media, badges, or confirmed receipt fail. Compare entitlements for every build configuration and inspect both targets in Xcode.

#### The extension runs but an image does not appear

Check `mutable-content`, the alert payload, target membership, embedded extension, native dependency, and App Group. Do not draw a conclusion from Dart logs because the extension runs outside the Flutter process.

#### Keeping verbose logs in release builds

Verbose logs help during debugging but can contain data that should not enter production logs. Enable them only through an explicit build class and sanitize logs before sharing them.

#### The gateway makes the code longer

The gateway, coordinator, and fake tests add abstraction compared with calling the singleton directly. A small app without authentication, deep links, or multiple lifecycle paths may not need this much structure. When an app has account switching, terminated-state clicks, and an iOS extension, the abstraction provides ownership and failure boundaries that can be tested.

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11.0, constrained below 4.0.
* `onesignal_flutter`: exactly `5.3.3`.
* iOS `OneSignalXCFramework`: resolved to `5.2.13`.
* iOS deployment target in the source: 15.0.
* Android min SDK in the source: 24; target SDK: 36.
* Platforms: Android and iOS.

OneSignal APIs and native setup can change. When upgrading the package, I recheck listener add/remove behavior, permission APIs, the user model, CocoaPods/SPM migration, extension dependencies, and killed-state behavior instead of changing only the version constraint.

### References

* [OneSignal — Flutter SDK setup](https://documentation.onesignal.com/docs/en/flutter-sdk-setup)
* [OneSignal — Mobile SDK reference](https://documentation.onesignal.com/docs/en/mobile-sdk-reference)
* [OneSignal — iOS SDK setup](https://documentation.onesignal.com/docs/en/ios-sdk-setup)
* [OneSignal — Mobile service extensions](https://documentation.onesignal.com/docs/en/service-extensions)
* [OneSignal — Prompt for push permissions](https://documentation.onesignal.com/docs/en/prompt-for-push-permissions)
* [OneSignal — Handling personal data](https://documentation.onesignal.com/docs/en/handling-personal-data)
* [Apple — Modifying content in newly delivered notifications](https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications)
* [Android Developers — Notification runtime permission](https://developer.android.com/develop/ui/compose/notifications/notification-permission)
* [OneSignal Flutter SDK 5.3.3 — notifications.dart](https://github.com/OneSignal/OneSignal-Flutter-SDK/blob/5.3.3/lib/src/notifications.dart)
* [OneSignal Flutter SDK 5.3.3 — onesignal\_flutter.dart](https://github.com/OneSignal/OneSignal-Flutter-SDK/blob/5.3.3/lib/onesignal_flutter.dart)

## Conclusion

What I keep from the source is that OneSignal already has boundaries for initialization, permission, identity, click routing, and the iOS extension. The part that needs hardening is ownership: initialization and listeners should belong to an app-level coordinator, permission should come from the operating system result, and identity should follow every auth lifecycle path.

On iOS, the Notification Service Extension is a real process boundary. A matching App Group, the correct native dependency, and a timeout fallback matter as much as the Flutter code. Static configuration proves only that the project declares these pieces. A release build on a physical device is still required to prove rich notifications and killed-state clicks.

This structure adds a gateway, coordinator, and fake tests. An app that sends only anonymous notifications and has no deep links can use a simpler design. When an app has multiple accounts, several logout paths, and iOS rich notifications, one clear owner for each state helps prevent duplicate listeners, subscriptions attached to the wrong user, and uncontrolled navigation.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
