Skip to main content
Supported on: iOS SDK 1.0.61+ · Android SDK 1.0.33+ · Flutter SDK 1.0.3+
The AppDNA Flutter SDK provides a complete push notification module for registering device tokens, tracking delivery and taps, and handling notification interactions through delegates and streams.

Platform Setup

Before using push notifications, complete the platform-specific setup:

iOS

  1. In Xcode, enable the Push Notifications capability under your target’s Signing & Capabilities tab.
  2. Generate an APNs authentication key (.p8 file) in the Apple Developer Portal.
  3. In the AppDNA Console under Settings > Push, in the APNs (iOS) card, paste the contents of the .p8 file into P8 Auth Key, fill in Key ID, Team ID and Bundle ID, pick the Environment, and click Save.

Android

  1. Set up Firebase Cloud Messaging (FCM) in your Android project.
  2. Add the google-services.json file to your android/app/ directory.
  3. Upload your Firebase service-account JSON to the AppDNA Console under Settings > Push, in the FCM (Android) card (paste it into Service Account JSON), enter your Firebase Project ID (the project_id in that JSON), and click Save. It must be a service-account key ("type": "service_account", with client_email and private_key); other Google credential types, such as a workload identity federation configuration, are refused.
Without the platform-specific credentials uploaded to the AppDNA Console, push notifications will not be delivered to your users.
Requires server ≥ this release. Delivered / tapped tracking, AppDNA.push.handleMessage / handleTap and AppDNAPush.handlePushTap() rely on the push_id, delivery_id and appdna marker that AppDNA’s servers add to every push from this release on; older servers sent no push_id.

Push Module

Access the push module through the AppDNA.push accessor, which returns an AppDNAPushModule instance:

Request Permission

Request push notification permission from the user. This presents the system permission dialog only — it does not register for remote notifications. To register for remote notifications, call AppDNA.push.registerForPush() (see below):
The method returns a bool indicating whether the user granted permission.

Set Token Manually

If you handle token registration yourself (e.g., using firebase_messaging), pass the token to the SDK. AppDNA sends iOS pushes directly over APNs, so the token differs per platform:
  • iOS: pass the APNs token — FirebaseMessaging.instance.getAPNSToken(). It returns null until iOS has registered with APNs (after the permission prompt), so null-check it and retry after requestPermission() resolves and on every app start (onTokenRefresh reports FCM tokens only).
  • Android: pass the FCM token — FirebaseMessaging.instance.getToken(), after configure and on every onTokenRefresh.
Never pass the FCM token (getToken()) on iOS. The SDK drops it silently, and every iOS push to that device then fails.
AppDNA.push.setToken(token) is the same call on the push module.

Get Current Token

Retrieve the registered push token:
Returns String? — null if no token has been registered.

Set Permission Status

Manually update the SDK with the current push permission status:
Or via the static method:

Track Delivery and Taps

trackDelivered / trackTapped (and the static trackPushDelivered / trackPushTapped) are only for pushes the SDK does not see. They carry no delivery_id, so combined with forwarding (AppDNA.push.handleMessage / handleTap, below) or the automatic iOS handler they double-count. For AppDNA pushes use the forwarding API instead — never both.
Track when a push notification is delivered to the device:
Track when a user taps on a push notification:
These are also available as static methods:

How push works on iOS

Requires Flutter SDK 1.0.20+.
The SDK installs a notification-centre delegate proxy automatically at app launch, right after application(_:didFinishLaunchingWithOptions:) returns. It wraps whatever delegate exists at that moment (FlutterAppDelegate, FlutterFire’s, your own), so a delegate set in didFinishLaunching is wrapped. A delegate assigned later replaces the proxy and cannot be re-wrapped (the proxy installs once per launch; installNotificationProxy() then does nothing): set it in didFinishLaunching instead, or forward AppDNA pushes from it natively with AppDNA.pushModule.handleMessageData / handleNotificationTap (see iOS push). The proxy does not change how your own notifications are presented or handled, unless you set AppDNAForegroundPresentation (see the trade-off below). AppDNA pushes (marked appdna: "1") are tracked and routed by AppDNA, including the cold-start tap — no forwarding and no AppDelegate code is needed on iOS.
  • FlutterFire: getInitialMessage() may also return an AppDNA push that launched the app, depending on install order (with messageId == null and data['appdna'] == '1'). Ignore such messages in your own routing (AppDNA already handled them), or pass them to AppDNA.push.handleTap(message.data) (deduplicated). onMessage / onMessageOpenedApp never fire for AppDNA pushes on iOS.
  • Foreground presentation and tracking. When FlutterFire ends up as the outer notification delegate, it keeps deciding how notifications are presented in the foreground — AppDNA pushes included — and AppDNA’s foreground delivered tracking does not run, unless AppDNAForegroundPresentation is set or the delegate AppDNA wrapped implements willPresent. To have AppDNA pushes shown and tracked in the foreground, set the Info.plist key AppDNAForegroundPresentation to an array of banner, list, sound, badge (an empty array = do not present). Trade-off: when AppDNA ends up innermost with no previous delegate, your own FCM pushes then get no foreground presentation — leave the key unset if presenting your own FCM pushes in the foreground matters more.
  • Opt out with the Info.plist key AppDNADisableNotificationProxy = YES; AppDNA pushes are then not tracked or routed unless your native iOS code forwards them (see iOS push).
  • Delivered tracking on iOS covers foreground delivery only; taps are tracked from any state.

Taps on Android

A tap that reaches your running app through the activity’s onNewIntent is handled by the plugin: body, button and text-reply taps on notifications the SDK displayed, a tap on a notification FCM displayed, and a tap that brings the app back after Android killed its process. Once the SDK is configured, the tap is tracked, routed and passed to onPushTapped. A tap that arrives before configure waits for it. The plugin hands the native SDK a copy of the intent, so the activity’s intent keeps its extras (appdna, push_id, delivery_id and the rest), and the tap is still recognised as AppDNA’s if you read them and forward the tap yourself. A tap that launches the app arrives in its launch intent. The plugin hands it to the native SDK when you call configure, as the React Native module does, so it is tracked, routed and passed to onPushTapped without any call from your app. The same goes for a tap that starts a new activity while the Flutter engine is still running (a cached engine, or the activity was closed with Back while the process lived): the plugin hands that activity’s launch intent over when it attaches to it. You can still call await AppDNAPush.handlePushTap(), or forward the tap through FlutterFire (getInitialMessage(), below): handlePushTap() answers for the newest intent only (the last one through onNewIntent, else the launch intent) and returns true for a tap the SDK has already handled without tracking or routing it again. It answers at once, also before configure or after shutdown(); a tap the SDK has not handled yet is then tracked and routed once the SDK is ready. Like the plugin’s own hand-over, it gives the native SDK a copy of the intent, so the intent keeps its extras. Every tap is tracked and routed once: the plugin hands each intent to the native SDK once (calling configure again after shutdown() does not re-run the launch intent), and the SDK remembers the taps it has handled (the last 32, keyed by delivery_id, else push_id), so the same tap forwarded through AppDNA.push.handleTap is not tracked again either. Set the push delegate before configure. The tap that launched the app is handed to the native SDK during configure, and onPushTapped reaches only a delegate that is already set at that moment: the native SDK keeps no tap for a delegate set later, so a delegate set after configure can miss the launch tap (the SDK still tracks and routes it). Pushes and taps after that reach a delegate set at any time.

If your app already uses Firebase Messaging (Android)

Requires Flutter SDK 1.0.20+.
The SDK ships an AppDNAMessagingService that handles only AppDNA messages (it ignores everything else). If your app uses firebase_messaging, keep it as the only handler and forward AppDNA messages to AppDNA:
  1. Remove the SDK’s service in android/app/src/main/AndroidManifest.xml:
    or set <bool name="appdna_messaging_service_enabled">false</bool> in android/app/src/main/res/values/bools.xml.
  2. Keep firebase_messaging as the only handler.
  3. Register the token as shown in Set Token Manually — the FCM token on Android, the APNs token on iOS.
  4. Forward messages your handler receives:
    handleMessage tracks delivery (with its delivery_id) and fires onPushReceived; it never displays anything (on Android it calls the native AppDNA.push.handleMessageData, not the native handleMessage, which posts the notification for any AppDNA message with a title or body). Display is unaffected: AppDNA pushes carry a notification block, which FCM displays while the app is in the background; on Android, in the foreground FCM displays nothing, so showing the message then is up to your app.
  5. Forward taps — taps that reach the running app, and the tap that launched it (at configure), are handled by the plugin (Taps on Android). Forwarding them as well is safe: await AppDNAPush.handlePushTap() after configure (it takes no argument; the plugin reads the newest intent — the last one through onNewIntent, else the launch intent — and leaves its extras in place), or:
    Both paths share one dedup, so doing both is safe. Every forwarding call returns false and does nothing for a message without the AppDNA marker, so you can forward every message.
Pass the marker as a string. AppDNA’s servers send appdna and push_id as strings ("1", "…"). FCM data values are always strings, so this matters only for a payload you build or rewrite yourself. Keep them strings: iOS reads only string values, so a numeric appdna: 1 is not an AppDNA push there, while the Android bridge converts a number to its string (1 → "1") and accepts it — the two platforms would disagree.
Do not also call trackDelivered / trackTapped for these messages.
Background messages (known limitation). A message that arrives while the app is in the background is handled by FirebaseMessaging.onBackgroundMessage in a separate isolate; FCM still displays the notification, and the tap is tracked when the app opens (Taps on Android). Forwarding delivered from the background handler is not supported in this release.

AppDNAPushDelegate

All 3 methods on this delegate fire from the SDK’s push pipeline:
  • onPushTokenRegistered(token) — fires whenever the SDK registers a new APNs (iOS) or FCM (Android) token (or detects a change). Token registration with the AppDNA backend continues regardless of whether you implement this method.
  • onPushReceived(notification, inForeground) — fires when a push arrives. notification is the full payload map; inForeground is true when the app is active.
  • onPushTapped(notification, actionId) — fires when the user taps a notification or one of its action buttons. actionId is the identifier of the tapped action button if the user tapped one, otherwise null.
Action buttons and custom sound (set on the Console’s push compose page) are displayed by the native SDKs. The rules are in iOS push and Android push. On iOS, add a Notification Service Extension whose class subclasses NotificationService from the extension-safe AppDNANotificationExtension pod (pod 'AppDNANotificationExtension' in the extension’s target block of your ios/Podfile; make it a top-level target, not one nested in the app’s target, which would inherit the app’s pods into the extension, or add inherit! :none to a nested one), so a new button set shows its buttons on the first push. Keep use_frameworks! in your ios/Podfile (Flutter’s generated Podfile has it): the iOS SDK cannot be built from pods built as static libraries (iOS installation). On Android, buttons appear only on notifications the native SDK builds itself. A push that FCM or your own Firebase Messaging handler displays has none. A button tap arrives as onPushTapped(notification, actionId) with the button’s id. The typed text of a text-reply button is in notification['data']['reply_text']. Implement the AppDNAPushDelegate abstract class to receive push notification lifecycle callbacks:

Example Implementation

AppDNAPush Helper Methods

The AppDNAPush class exposes static helpers for permission and token/tap plumbing. Push lifecycle callbacks arrive through AppDNA.push.setDelegate(...) (above) — AppDNAPush has no streams.

Notification Payload Shape

There is no public PushPayload Dart class. The notification argument delivered to the AppDNAPushDelegate callbacks is a Map<String, dynamic> with camelCase keys. Nested values have real Dart types, so the casts below are safe:

Auto-Tracked Events

The SDK automatically tracks the following push-related events: Only AppDNA pushes (marked appdna: "1") are tracked; your own FCM messages are not.
Auto-tracked events are sent alongside any manually tracked events. You do not need to track these events yourself.

Full Example

Push notification behavior varies between iOS and Android. On iOS, the system permission dialog is shown on first request. On Android 13+, the POST_NOTIFICATIONS runtime permission is requested. The native SDKs handle these differences transparently.

Scheduled pushes in the user’s time zone

A campaign scheduled with Send in user’s timezone reaches each user when their own clock shows the scheduled date and time. Each user gets it once, at the first check (every minute) that finds their local time has reached it — also when a daylight-saving change or a move to another zone happens while the campaign is going out. The audience is fixed when the campaign starts going out, in the first zone to reach the time. From Flutter SDK 1.0.20, every event the SDK sends carries the device’s time zone (its IANA name, read by the native SDK), and AppDNA keeps the latest one on the identified user’s profile — so you need no code for this. Push quiet hours and journey waits that end at a time of day use the same zone. The zone is resolved in this order:
  1. the timezone trait your app passes to identify (an IANA name such as Europe/Berlin), if you set one — it always takes precedence, and the device’s zone never overwrites it;
  2. the zone the user’s device last reported;
  3. the app’s time zone;
  4. UTC.
A value that is not an IANA zone name is ignored at each step. The device’s zone is recorded for identified users only (after identify), from their next event. Set the trait only when the zone your app knows for the user should win over the device’s — for example a zone the user picked in your settings: