Add push notifications to your iOS app in minutes. The SDK handles APNs device registration, permission management, rich media attachments, badge tracking, and analytics (delivered, opened, clicked) out of the box — with no Firebase dependency.
- User targeting — segment by tags, language, aliases, and email
- Rich notifications — images, videos, action buttons, and text overrides via a Notification Service Extension
- Analytics — delivered, opened, and clicked events tracked automatically across foreground, background, and killed states
- Flexible — works with UIKit, SwiftUI, and Objective-C; swizzling is optional
Warning
Beta release — not for production use.
1.0.2-beta is an early access release intended for evaluation, integration testing, and
prototype builds. Do not ship this version in a production app or a build with a large user base.
- The public API may change between releases without a deprecation period.
- Breaking changes are not restricted to major versions while the SDK is in beta.
- Behavior in production-scale environments has not been fully validated.
Use a patch-permissive constraint so patch releases flow through automatically, and re-test your integration on every upgrade.
For full documentation visit documentation.appsonair.com.
| Platform | Support | Notes |
|---|---|---|
| Swift (UIKit) | ✅ Full | Native API — recommended |
| Swift (SwiftUI) | ✅ Full | Use @UIApplicationDelegateAdaptor — see Quick Start |
| Objective-C | ✅ Full | Complete AOA* facade for all main app APIs. NSE: use AOAPushExtension. CE: subclass AOAContentViewController |
| Flutter | ✅ Full | Use the dedicated AppsOnAir Flutter SDK |
| React Native | ✅ Full | Use the dedicated AppsOnAir React Native SDK |
- Requirements
- Installation
- Apple Setup
- Quick Start
- Debug Logging
- User Identity
- User Namespace
- Notifications Namespace
- Badge Count
- GDPR Consent
- Silent Push
- Swizzling
- Notification Service Extension
- Notification Content Extension
- Background Fetch
- Push Payload Reference
- Full API Reference
- PushListener Protocol
- Error Codes
- Simulator Notes
- Troubleshooting
| Minimum | |
|---|---|
| iOS | 15.0 |
| Xcode | 16+ |
| Swift | 6.2 (SPM) · 5.9 (CocoaPods) |
| Objective-C | Fully supported via AOA* facade classes |
The SDK ships three products. Link the right one to each target — never add NSE or CE products to the main app target:
| Product | Link to | SPM name | CocoaPods |
|---|---|---|---|
| Main app SDK | Main app target | AppsOnAir-AppPush |
AppsOnAir-AppPush |
| Notification Service Extension | NSE target only | AppsOnAir-AppPush-ServiceExt |
AppsOnAir-AppPush-ServiceExt (1.0.6-beta+) |
| Notification Content Extension | CE target only | AppsOnAir-AppPush-ContentExt |
AppsOnAir-AppPush-ContentExt (1.0.6-beta+) |
In Xcode: File → Add Package Dependencies → enter the repository URL:
https://github.com/apps-on-air/appsonair-ios-push-notification
Set the version rule to Up to Next Minor Version from 1.0.2-beta — this accepts patch releases automatically and blocks minor bumps (1.1+) that may carry breaking changes. Link products per the table above.
Warning
CocoaPods is winding down active development. Swift Package Manager (SPM) is the recommended integration method — zero warnings, explicit product linking, and fully supported by Apple.
NSE and CE targets: use the AppsOnAir-AppPush-ServiceExt / AppsOnAir-AppPush-ContentExt pods (1.0.6-beta+). Their modules (AppsOnAir_AppPush_ServiceExt / AppsOnAir_AppPush_ContentExt) match the SPM products, so extension code is the same with either package manager.
The older AppsOnAir-AppPush/ServiceExtension and AppsOnAir-AppPush/ContentExtension subspecs are deprecated. A subspec shares the main pod's module name, so it builds a second AppsOnAir_AppPush.framework: with use_frameworks! (common in React Native and Flutter apps) archive fails with "Multiple commands produce …/AppsOnAir_AppPush.framework", and the app can crash at launch because the extension's framework is embedded in its place. To migrate, switch the pod and change import AppsOnAir_AppPush to import AppsOnAir_AppPush_ServiceExt (NSE) / import AppsOnAir_AppPush_ContentExt (CE).
Mixing CocoaPods (main app) with SPM (extensions) also keeps working.
# '>= 1.0.6-beta', '< 1.1' — accepts patch releases automatically; blocks minor bumps.
target 'MyApp' do
pod 'AppsOnAir-AppPush', '>= 1.0.6-beta', '< 1.1'
end
target 'MyNotificationServiceExtension' do
pod 'AppsOnAir-AppPush-ServiceExt', '>= 1.0.6-beta', '< 1.1'
end
target 'MyNotificationContentExtension' do
pod 'AppsOnAir-AppPush-ContentExt', '>= 1.0.6-beta', '< 1.1'
end-
Push Notifications capability Target → Signing & Capabilities → + Capability → Push Notifications
-
Background Modes
- Capability → Background Modes → check Remote notifications
-
App ID in Info.plist (main app target only)
<key>AppsonairAppId</key> <string>your-app-id-here</string>
-
Use a real device for push testing — APNs tokens don't exist on simulator (the SDK emits a mock token so the rest of your flow still works).
import AppsOnAir_AppPush
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
AppPushService.Debug.logLevel = .verbose // development only — remove for production
AppsOnAirBackgroundSync.registerHandlers() // must be called before any scene connects
AppPushService.initialize(debug: true) // set debug: false for production
AppPushService.setListener(self)
AppPushService.requestPermission()
AppsOnAirBackgroundSync.scheduleIfNeeded()
return true
}
}
extension AppDelegate: PushListener {
func onAPNsTokenUpdated(token: String, environment: APNsEnvironment) {
// No action required — the SDK automatically registers this device.
// setSubscriptionId() is only needed if you want to set a value manually.
}
func onNotificationReceived(notification: PushNotification) {
print("Foreground push: \(notification.title ?? "")")
}
func onNotificationOpened(notification: PushNotification) {
// Navigate using notification.id or notification.userInfo
}
func onError(_ error: PushError) {
print("[\(error.code)] \(error.message)")
}
}import SwiftUI
import AppsOnAir_AppPush
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup { ContentView() }
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
AppsOnAirBackgroundSync.registerHandlers()
AppPushService.initialize(debug: true) // set debug: false for production
AppPushService.setListener(self)
AppPushService.requestPermission()
AppsOnAirBackgroundSync.scheduleIfNeeded()
return true
}
}
extension AppDelegate: PushListener {
func onAPNsTokenUpdated(token: String, environment: APNsEnvironment) { }
func onNotificationReceived(notification: PushNotification) { }
func onNotificationOpened(notification: PushNotification) { }
func onError(_ error: PushError) { }
}// AppDelegate.h
#import <UIKit/UIKit.h>
@import AppsOnAir_AppPush;
@interface AppDelegate : UIResponder <UIApplicationDelegate, AOAPushListener>
@property (strong, nonatomic) UIWindow *window;
@end// AppDelegate.m
#import "AppDelegate.h"
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[AOAPushDebug setLogLevel:AOALogLevelVerbose]; // development only — remove for production
[AOAPushBackgroundSync registerHandlers]; // must be called before any scene connects
[AOAPush initializeWithDebug:YES swizzle:YES]; // set NO for production
[AOAPush setListener:self];
[AOAPushNotifications requestPermission];
[AOAPushBackgroundSync scheduleIfNeeded];
return YES;
}
// Silent push — wakes the app in the background for lightweight work (≤ 30 s)
- (void)application:(UIApplication *)application
didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
[AOAPush handleSilentPush:userInfo fetchCompletionHandler:completionHandler];
}
// AOAPushListener — all methods are @optional
- (void)onAPNsTokenUpdatedWithToken:(NSString *)token
environment:(AOAAPNsEnvironment)environment {
// SDK registers this device automatically — no action required.
// Call [AOAPush setSubscriptionId:@"..."] only if you need a manual override.
}
- (void)onNotificationReceivedWithNotification:(AOAPushNotification *)notification {
NSLog(@"Foreground push: %@", notification.title);
}
- (void)onNotificationOpenedWithNotification:(AOAPushNotification *)notification {
// Navigate using notification.identifier or notification.userInfo
}
- (void)onError:(NSError *)error {
NSLog(@"[%ld] %@", (long)error.code, error.localizedDescription);
}
@endSet the log level before initialize() to see startup output.
// Swift
AppPushService.Debug.logLevel = .verbose// Objective-C
[AOAPushDebug setLogLevel:AOALogLevelVerbose];| Level | What you see |
|---|---|
.none |
Nothing (default for production) |
.fatal |
Fatal errors only |
.error |
Errors that affect SDK behaviour |
.warn |
Unexpected but recoverable situations |
.info |
Key lifecycle events |
.debug |
Detailed SDK flow |
.verbose |
Everything |
login() links this device to your user's account; logout() unlinks it — the device reverts to anonymous and continues receiving pushes.
Call login when your user signs in and logout when they sign out. Tags, aliases, and language are cleared on logout — the device keeps receiving pushes as an anonymous user until the next login.
// Swift
AppPushService.login("user_12345") // call when your user signs in
AppPushService.logout() // call on sign-out — clears tags and aliases// Objective-C
[AOAPush login:@"user_12345"];
[AOAPush logout];User data attached to this device — tags for segmentation, language for localised sends, aliases to match your CRM records, email addresses, and the push subscription state (opt-in/opt-out).
Key-value strings attached to a user for audience segmentation — plan, region, tier, etc. Both sync operations (add/remove) and reads are available.
// Swift
AppPushService.User.addTag(key: "plan", value: "premium")
AppPushService.User.addTags(["plan": "premium", "region": "us"])
AppPushService.User.removeTag("plan")
AppPushService.User.removeTags(["plan", "region"])
// Synchronous — returns locally stored tags
let tags = AppPushService.User.getTags()
// Async — fetches latest tags from the server
AppPushService.User.getTags { tags in
print(tags)
}// Objective-C
[AOAPushUser addTagWithKey:@"plan" value:@"premium"];
[AOAPushUser addTags:@{@"plan": @"premium", @"region": @"us"}];
[AOAPushUser removeTag:@"plan"];
[AOAPushUser removeTags:@[@"plan", @"region"]];
// Synchronous — returns locally stored tags
NSDictionary *tags = [AOAPushUser getTags];
// Async — fetches latest tags from the server
[AOAPushUser fetchTagsFromBackendWithCompletion:^(NSDictionary *tags) {
NSLog(@"%@", tags);
}];Override the device locale for this user. Useful when your backend sends localised content and the device language doesn't match the user's preference. Use ISO 639-1 codes ("en", "fr", "de").
// Swift
AppPushService.User.setLanguage("fr") // ISO 639-1 code
let lang = AppPushService.User.language// Objective-C
[AOAPushUser setLanguage:@"fr"];
NSString *lang = [AOAPushUser language];Map this device to an ID in an external system — CRM, helpdesk, analytics, etc. A label identifies the system ("crm_id", "hubspot_id"), the id is the value from that system.
// Swift
AppPushService.User.addAlias(label: "crm_id", id: "CRM-9876")
AppPushService.User.addAliases(["crm_id": "CRM-9876", "hubspot_id": "HS-42"])
AppPushService.User.removeAlias("crm_id")
AppPushService.User.removeAliases(["crm_id", "hubspot_id"])
// Synchronous — returns locally stored aliases
let aliases = AppPushService.User.getAliases()
// Async — fetches latest aliases from the server
AppPushService.User.getAliases { aliases in
print(aliases)
}// Objective-C
[AOAPushUser addAliasWithLabel:@"crm_id" id:@"CRM-9876"];
[AOAPushUser addAliases:@{@"crm_id": @"CRM-9876", @"hubspot_id": @"HS-42"}];
[AOAPushUser removeAlias:@"crm_id"];
[AOAPushUser removeAliases:@[@"crm_id", @"hubspot_id"]];
// Synchronous — returns locally stored aliases
NSDictionary *aliases = [AOAPushUser getAliases];
// Async — fetches latest aliases from the server
[AOAPushUser fetchAliasesFromBackendWithCompletion:^(NSDictionary *aliases) {
NSLog(@"%@", aliases);
}];Associate an email address with this user record. The backend keeps one email per subscription — calling addEmail again replaces the previous address.
// Swift
AppPushService.User.addEmail("[email protected]")
AppPushService.User.removeEmail("[email protected]")// Objective-C
[AOAPushUser addEmail:@"[email protected]"];
[AOAPushUser removeEmail:@"[email protected]"];Lets the user stop receiving pushes without revoking OS-level permission. Opt back in at any time and pushes resume immediately.
// Swift
AppPushService.User.pushSubscription.optOut() // stop receiving pushes
AppPushService.User.pushSubscription.optIn()
let isOptedIn = AppPushService.User.pushSubscription.optedIn
let token = AppPushService.User.pushSubscription.token
let subId = AppPushService.User.pushSubscription.id// Objective-C
[AOAPushUser optOut];
[AOAPushUser optIn];
BOOL optedIn = [AOAPushUser pushSubscriptionOptedIn];
NSString *token = [AOAPushUser pushSubscriptionToken];
NSString *subId = [AOAPushUser pushSubscriptionId];React to opt-in/out changes and login/logout events without polling. Add observers early — they fire immediately with the current state on first add.
// Swift
// Observe opt-in / token changes
AppPushService.User.pushSubscription.addObserver(self)
AppPushService.User.pushSubscription.removeObserver(self)
func onPushSubscriptionDidChange(state: PushSubscriptionChangedState) {
print("optedIn:", state.current.optedIn)
}
// Observe login / logout
AppPushService.User.addObserver(self)
AppPushService.User.removeObserver(self)
func onUserStateDidChange(state: UserChangedState) {
print(state.current.externalId ?? "anonymous")
}// Objective-C
// AOAPushSubscriptionObserver
[AOAPushUser addPushSubscriptionObserver:self];
[AOAPushUser removePushSubscriptionObserver:self];
- (void)onPushSubscriptionDidChangeWithState:(AOAPushSubscriptionChangedState *)state {
NSLog(@"optedIn: %d", state.current.optedIn);
}
// AOAUserStateObserver
[AOAPushUser addUserStateObserver:self];
[AOAPushUser removeUserStateObserver:self];
- (void)onUserStateDidChangeWithState:(AOAUserChangedState *)state {
NSLog(@"%@", state.current.externalId ?: @"anonymous");
}Handles OS permission requests, foreground display behaviour, click/action callbacks, and removing notifications from the tray.
// Swift
AppPushService.Notifications.requestPermission()
AppPushService.Notifications.requestPermission(fallbackToSettings: true) // opens Settings if denied
AppPushService.Notifications.registerForProvisionalAuthorization() // iOS 12+ quiet notifications
let granted = AppPushService.Notifications.permission // Bool, synchronous
let status = AppPushService.Notifications.permissionNative // enum — notDetermined / denied / authorized / provisional / ephemeral
let canAsk = AppPushService.Notifications.canRequestPermission // true when dialog would appear
// Force re-read from the OS
let fresh = await AppPushService.Notifications.refreshPermission()// Objective-C
[AOAPushNotifications requestPermission];
[AOAPushNotifications requestPermissionWithFallbackToSettings:YES];
[AOAPushNotifications registerForProvisionalAuthorization];
BOOL granted = [AOAPushNotifications permission];
AOANotificationPermission status = [AOAPushNotifications permissionNative];
BOOL canAsk = [AOAPushNotifications canRequestPermission];
[AOAPushNotifications refreshPermissionWithCompletion:^(BOOL granted) {
NSLog(@"granted: %d", granted);
}];Get notified when the user changes notification permission in Settings. Useful for updating UI that reflects the current permission state.
// Swift
AppPushService.Notifications.addPermissionObserver(self)
AppPushService.Notifications.removePermissionObserver(self)
func onNotificationPermissionDidChange(_ permission: Bool) { }// Objective-C — AOANotificationPermissionObserver
[AOAPushNotifications addPermissionObserver:self];
[AOAPushNotifications removePermissionObserver:self];
- (void)onNotificationPermissionDidChange:(BOOL)permission { }By default the SDK shows banners even when the app is in the foreground. Add a lifecycle listener to intercept and call preventDefault() on any notification you want to handle silently.
This hook controls how the notification is displayed on screen — it has no effect on delivery analytics.
// Swift
AppPushService.Notifications.addForegroundLifecycleListener(self)
AppPushService.Notifications.removeForegroundLifecycleListener(self)
func onWillDisplay(event: NotificationWillDisplayEvent) {
// Call preventDefault() to suppress the banner; omit to show it normally
event.preventDefault()
}// Objective-C — AOANotificationLifecycleListener
[AOAPushNotifications addForegroundLifecycleListener:self];
[AOAPushNotifications removeForegroundLifecycleListener:self];
- (void)onWillDisplayWithEvent:(AOANotificationWillDisplayEvent *)event {
[event preventDefault];
}Fired when the user taps a notification or one of its action buttons. Use event.result.actionId to distinguish which button was tapped, and event.result.url for deep link handling.
Analytics for opens and clicks are reported automatically — no extra code required.
// Swift
AppPushService.Notifications.addClickListener(self)
AppPushService.Notifications.removeClickListener(self)
func onClick(event: NotificationClickEvent) {
print(event.result.actionId ?? "body tap")
print(event.result.url ?? "")
}// Objective-C — AOANotificationClickListener
[AOAPushNotifications addClickListener:self];
[AOAPushNotifications removeClickListener:self];
- (void)onClickWithEvent:(AOANotificationClickEvent *)event {
NSLog(@"action: %@ url: %@",
event.result.actionId ?: @"body tap",
event.result.url ?: @"");
}Remove delivered notifications from the tray — all at once or by identifier.
// Swift
AppPushService.Notifications.clearAllNotifications()
AppPushService.Notifications.removeNotification(withIdentifier: "abc")
AppPushService.Notifications.removeNotifications(withIdentifiers: ["a", "b"])// Objective-C
[AOAPushNotifications clearAllNotifications];
[AOAPushNotifications removeNotificationWithIdentifier:@"abc"];
[AOAPushNotifications removeNotificationsWithIdentifiers:@[@"a", @"b"]];The SDK tracks a running total — each push increments the count rather than overwriting it.
// Swift
AppPushService.setBadgeCount(5)
AppPushService.incrementBadgeCount(by: 1) // delta can be negative
AppPushService.clearBadgeCount()
let n = AppPushService.badgeCount
// Auto-clear on foreground (default = true)
AppPushService.autoClearBadgeOnForeground = false // switch to count-down mode// Objective-C
[AOAPush setBadgeCount:5];
[AOAPush incrementBadgeCountBy:1];
[AOAPush clearBadgeCount];
NSInteger n = [AOAPush badgeCount];
[AOAPush setAutoClearBadgeOnForeground:NO];Two badge modes:
| Mode | autoClearBadgeOnForeground |
What happens on foreground |
|---|---|---|
| Clear (default) | true |
Badge resets to 0 whenever the app comes to the foreground |
| Count-down | false |
Badge stays; decremented by 1 each time the user opens a notification |
Push-driven badge updates (badge / badge_increment payload keys) work via the Notification Service Extension and require an App Group — see NSE setup.
Note: Consent enforcement is not yet active. The SDK does not yet gate network calls behind these values. Full enforcement is coming in a future release.
Set consentRequired = true before initialize() if your app needs explicit user consent before any data is sent. Consent is persisted — you don't need to set it again on relaunch.
// Swift — set BEFORE initialize()
AppPushService.consentRequired = true
AppPushService.initialize()
// After the user accepts your consent dialog:
AppPushService.consentGiven = true// Objective-C
[AOAPush setConsentRequired:YES]; // before initialize
[AOAPush initializeWithDebug:NO swizzle:YES];
[AOAPush setConsentGiven:YES]; // after user acceptsA silent push (content-available: 1, no alert) wakes the app in the background for up to 30 seconds. Good for lightweight syncs — refresh a badge count, pull new content, update local state — without showing a banner.
// Swift
AppPushService.onSilentPushReceived = { userInfo, completion in
// run lightweight background work (≤ 30 s)
completion(.newData)
}// Objective-C — UIApplicationDelegate
- (void)application:(UIApplication *)application
didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))handler {
[AOAPush handleSilentPush:userInfo fetchCompletionHandler:handler];
}Silent push requires specific APNs HTTP/2 headers. Using alert push headers with a silent payload (or vice versa) causes APNs to return 200 OK but iOS silently drops the notification.
| APNs header | Silent push value | Alert push value |
|---|---|---|
apns-push-type |
background |
alert |
apns-priority |
5 |
10 |
Correct silent push payload — no alert, no sound:
{
"aps": {
"content-available": 1
},
"your_key": "your_value"
}Important
Never mix headers. An alert payload (apns-push-type: alert) with apns-priority: 5 will be dropped. A silent payload (apns-push-type: background) with apns-priority: 10 will be dropped. APNs returns 200 OK in both cases but the device never receives the notification.
These iOS settings must be active on the test device or silent push will never be delivered, regardless of payload or headers:
| Requirement | Where to check |
|---|---|
| Background App Refresh is ON | iPhone Settings → General → Background App Refresh → Wi-Fi & Cellular Data |
| Low Power Mode is OFF | iPhone Settings → Battery → Low Power Mode (OFF) — iOS suspends silent push entirely in Low Power Mode |
| App is backgrounded, not force-killed | Press Home once to background. Do not swipe up in App Switcher — iOS will not wake a force-killed app for silent push |
Swizzling is on by default — the SDK hooks APNs token and notification delegate methods automatically, so the AppDelegate needs no push code. This also holds in React Native and Flutter apps, and when another component owns UNUserNotificationCenter.current().delegate (for example Notifee, a Flutter plugin, or your own AppDelegate): the SDK hooks that delegate so AppsOnAir pushes still reach it, and the delegate's own code keeps running. Forwarding the callbacks by hand as well is harmless — each notification is processed once.
If you initialize with swizzle: false you need to forward the callbacks yourself.
// Swift — swizzle: false
AppPushService.initialize(swizzle: false)
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
AppPushService.handleAPNsToken(deviceToken)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
AppPushService.handleAPNsRegistrationError(error)
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler handler: @escaping (UNNotificationPresentationOptions) -> Void) {
handler(AppPushService.handleWillPresent(notification: notification))
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: @escaping () -> Void) {
AppPushService.handleDidReceive(response: response)
handler()
}// Objective-C — swizzle: false
[AOAPush initializeWithDebug:NO swizzle:NO];
- (void)application:(UIApplication *)app
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
[AOAPush handleAPNsToken:deviceToken];
}
- (void)application:(UIApplication *)app
didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
[AOAPush handleAPNsRegistrationError:error];
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))handler {
handler([AOAPush handleWillPresentWithNotification:notification]);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))handler {
[AOAPush handleDidReceiveWithResponse:response];
handler();
}One NSE gives you rich media (image/video attachments, text overrides) and Delivered analytics.
1. Add App Group to your main app target
In Xcode: Target → Signing & Capabilities → + → App Groups → add group.com.yourcompany.app.appsonair
Important
Xcode automatically adds the group to your .entitlements file — but it does not add anything to Info.plist. You must add the AppsOnAirAppGroup key to Info.plist manually. Skipping this step causes the SDK to fail reading shared storage (APNs token and permission state will not appear).
Add the key manually to both the main app and NSE Info.plist:
<key>AppsOnAirAppGroup</key>
<string>group.com.yourcompany.app.appsonair</string>After adding via Signing & Capabilities your .entitlements will contain (added automatically by Xcode):
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yourcompany.app.appsonair</string>
</array>Both files are required — the .entitlements entry tells iOS the app is allowed to access the group container; the Info.plist key tells the SDK which group ID to use.
If you name the group following the convention group.<main-bundle-id>.appsonair exactly, the SDK finds it automatically without the Info.plist key.
2. Add a Notification Service Extension target
File → New Target → Notification Service Extension.
Add the same App Group capability to the NSE target (Signing & Capabilities → App Groups), then add the same AppsOnAirAppGroup key to the NSE Info.plist as well.
Important
The App Group must be registered in Apple Developer Portal → Identifiers for both your main app App ID and your NSE App ID before you regenerate the provisioning profiles. Adding it only in Xcode Signing & Capabilities is not enough — the provisioning profile will not include the entitlement until you regenerate it in the portal.
The CE App ID does not need the App Group — the Content Extension only renders UI and does not access shared storage.
3. Link the SDK to the NSE target only
SPM: add AppsOnAir-AppPush-ServiceExt to the NSE target only — never to the main app target.
CocoaPods: add pod 'AppsOnAir-AppPush-ServiceExt' to the NSE target only and import AppsOnAir_AppPush_ServiceExt — not the deprecated ServiceExtension subspec (see Installation → CocoaPods).
4. Add mutable-content: 1 to every push payload
{
"aps": { "mutable-content": 1, "alert": { "title": "…", "body": "…" } },
"notification_id": "notif-abc-123",
"image_url": "https://cdn.example.com/image.jpg"
}Without this iOS never invokes the NSE.
import AppsOnAir_AppPush_ServiceExt
class NotificationService: AppsOnAirNotificationServiceExtension {
// No code required — media download, text overrides, badge, delivery receipt are automatic.
// Optional hook — runs before the SDK applies its changes:
// override func modifyContent(_ content: UNMutableNotificationContent,
// request: UNNotificationRequest) {
// content.title = "[Modified] " + content.title
// }
}import AppsOnAir_AppPush_ServiceExt
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent
else { return handler(request.content) }
AppPushServiceExtension.didReceiveNotificationExtensionRequest(
request, with: content, withContentHandler: handler)
}
override func serviceExtensionTimeWillExpire() { /* SDK handles this */ }
}ObjC cannot subclass
AppsOnAirNotificationServiceExtension. SubclassUNNotificationServiceExtensiondirectly and callAOAPushExtensionstatic methods — behaviour is identical.
// NotificationService.h
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
@interface NotificationService : UNNotificationServiceExtension
@end
// NotificationService.m
#import "NotificationService.h"
@import AppsOnAir_AppPush_ServiceExt;
@interface NotificationService ()
@property (nonatomic, strong) UNNotificationRequest *receivedRequest;
@property (nonatomic, copy) void (^contentHandler)(UNNotificationContent *);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
@end
@implementation NotificationService
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request
withContentHandler:(void (^)(UNNotificationContent *))contentHandler {
self.receivedRequest = request;
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
[AOAPushExtension didReceiveNotificationRequest:request
withContentHandler:contentHandler];
self.contentHandler = nil;
}
- (void)serviceExtensionTimeWillExpire {
[AOAPushExtension serviceExtensionTimeWillExpireRequest:self.receivedRequest
bestAttemptContent:self.bestAttemptContent
contentHandler:self.contentHandler];
self.contentHandler = nil;
}
@endReplaces the expanded (long-press) notification view with custom UI. There are two ways to set this up — choose the one that fits your needs.
| Option A — SDK built-in UI | Option B — Custom storyboard UI | |
|---|---|---|
| UI built by | SDK (AppsOnAirContentViewController) |
You (storyboard + code) |
| Storyboard | Delete it | Keep it |
| SDK pod required | Yes | No |
| ObjC subclassing | Not supported — use Option B | Fully supported |
| Best for | Quick setup, standard image+title+body layout | Custom layouts, ObjC apps |
Send "aps": { "category": "your-category-id" } in the payload to route the notification to this extension.
The SDK provides AppsOnAirContentViewController which renders a full-width image (from the NSE attachment), bold title, and multiline body automatically.
1. Add a Notification Content Extension target
File → New Target → Notification Content Extension.
2. Link the SDK to the CE target only
SPM: add AppsOnAir-AppPush-ContentExt to the CE target only — never to the main app target.
CocoaPods: add pod 'AppsOnAir-AppPush-ContentExt' to the CE target only and import AppsOnAir_AppPush_ContentExt — not the deprecated ContentExtension subspec (see Installation → CocoaPods).
3. Update Info.plist
Xcode generates NSExtensionMainStoryboard by default — replace it with NSExtensionPrincipalClass:
<!-- Remove this (Xcode default): -->
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<!-- Add this instead: -->
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>Full Info.plist after the change:
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<string>your-category-id</string>
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>1</real>
</dict>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
</dict>4. Delete MainInterface.storyboard
Right-click MainInterface.storyboard in the Xcode project navigator → Delete → Move to Trash. The SDK builds its UI entirely in code — the storyboard is not used.
5. Subclass AppsOnAirContentViewController
import AppsOnAir_AppPush_ContentExt
class NotificationViewController: AppsOnAirContentViewController {
// No code required — image, title, and body are rendered automatically.
}To add custom behaviour on top of the built-in layout, override configure(with:):
class NotificationViewController: AppsOnAirContentViewController {
override func configure(with notification: UNNotification) {
super.configure(with: notification) // keeps image + title + body
// add your own views or customisations here
}
}Note:
AppsOnAirContentViewControllercannot be subclassed from Objective-C. Use Option B instead.
Use this when you want full control over the layout, have an existing storyboard-based CE, or are working in Objective-C.
No SDK pod is required for this option — the CE uses only Apple's UserNotifications and UserNotificationsUI frameworks. The NSE still handles media downloads and delivery analytics.
Keep the Xcode-generated Info.plist as-is — NSExtensionMainStoryboard stays:
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<string>your-category-id</string>
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>1</real>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
</dict>Swift — implement UNNotificationContentExtension in your storyboard view controller:
import UIKit
import UserNotifications
import UserNotificationsUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
@IBOutlet var imageView: UIImageView!
@IBOutlet var titleLabel: UILabel!
@IBOutlet var bodyLabel: UILabel!
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
titleLabel.text = content.title
bodyLabel.text = content.body
if let attachment = content.attachments.first,
attachment.url.startAccessingSecurityScopedResource() {
defer { attachment.url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: attachment.url) {
imageView.image = UIImage(data: data)
}
}
}
}Objective-C — Option A (SDK built-in UI, no storyboard):
Use AOAContentViewController — the ObjC-subclassable equivalent of AppsOnAirContentViewController. It renders the same full-width image, bold title, and multiline body layout.
Delete MainInterface.storyboard. Set Info.plist to use NSExtensionPrincipalClass:
<key>NSExtensionPrincipalClass</key>
<string>NotificationViewController</string>// NotificationViewController.h
@import AppsOnAir_AppPush_ContentExt;
@interface NotificationViewController : AOAContentViewController
@end
// NotificationViewController.m
#import "NotificationViewController.h"
@implementation NotificationViewController
// No code required — image, title, and body are rendered by AOAContentViewController.
// Optional: override to add custom behaviour on top of the built-in layout.
- (void)configureWithNotification:(UNNotification *)notification {
[super configureWithNotification:notification]; // keeps image + title + body
// add your own customisation here
}
@endObjective-C — Option B (custom storyboard UI):
Keep MainInterface.storyboard and NSExtensionMainStoryboard in Info.plist. No SDK CE class involved.
// NotificationViewController.h
#import <UIKit/UIKit.h>
#import <UserNotifications/UserNotifications.h>
#import <UserNotificationsUI/UserNotificationsUI.h>
@interface NotificationViewController : UIViewController <UNNotificationContentExtension>
@property (nonatomic, weak) IBOutlet UIImageView *imageView;
@property (nonatomic, weak) IBOutlet UILabel *titleLabel;
@property (nonatomic, weak) IBOutlet UILabel *bodyLabel;
@end
// NotificationViewController.m
#import "NotificationViewController.h"
@implementation NotificationViewController
- (void)didReceiveNotification:(UNNotification *)notification {
self.titleLabel.text = notification.request.content.title;
self.bodyLabel.text = notification.request.content.body;
UNNotificationAttachment *attachment = notification.request.content.attachments.firstObject;
if (attachment && [attachment.URL startAccessingSecurityScopedResource]) {
NSData *data = [NSData dataWithContentsOfURL:attachment.URL];
[attachment.URL stopAccessingSecurityScopedResource];
if (data) self.imageView.image = [UIImage imageWithData:data];
}
}
@endWire imageView, titleLabel, and bodyLabel as @IBOutlet connections in MainInterface.storyboard.
The SDK uses a background task to keep subscription state and analytics in sync while the app is in the background. Register the handler at launch, schedule it once — iOS controls when it actually fires.
Add the task identifier to Info.plist first:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.appsonair.push.background-sync</string>
</array>// Swift
AppsOnAirBackgroundSync.registerHandlers() // call before any scene connects
AppsOnAirBackgroundSync.scheduleIfNeeded() // default 15-min minimum interval
AppsOnAirBackgroundSync.scheduleIfNeeded(minimumDelay: 1800) // custom interval (seconds)
AppsOnAirBackgroundSync.cancelPending()
let taskId = AppsOnAirBackgroundSync.taskIdentifier// Objective-C
[AOAPushBackgroundSync registerHandlers];
[AOAPushBackgroundSync scheduleIfNeeded];
[AOAPushBackgroundSync scheduleIfNeededWithMinimumDelay:1800];
[AOAPushBackgroundSync cancelPending];
NSString *taskId = [AOAPushBackgroundSync taskIdentifier];iOS controls actual run frequency — the minimum delay is a hint, not a guarantee.
The custom keys your backend sends alongside the standard aps dictionary. notification_id is the only required custom key — everything else is optional.
{
"aps": { "alert": { "title": "Hello", "body": "World" } },
"notification_id": "notif-abc-123"
}{
"aps": {
"alert": { "title": "New offer", "body": "Tap to view" },
"mutable-content": 1
},
"notification_id": "notif-abc-123",
"image_url": "https://cdn.example.com/offer.jpg"
}{
"aps": {
"alert": { "title": "New message" },
"mutable-content": 1
},
"notification_id": "notif-abc-123",
"badge_increment": 1
}| Key | Type | Notes |
|---|---|---|
notification_id |
String | Available in callbacks as notification.id |
send_id |
String | Analytics send identifier. Available as notification.sendId |
title |
String | Overrides aps.alert.title. NSE only |
body |
String | Overrides aps.alert.body. NSE only |
subtitle |
String | Overrides aps.alert.subtitle. NSE only |
big_picture |
String (https) | Primary image. NSE only. Takes precedence over image_url and large_icon |
image_url |
String (https) | Image attachment fallback. NSE only. Used when big_picture is absent |
large_icon |
String (https) | Fallback image. NSE only. Used only when neither big_picture nor image_url is present |
video_url |
String (https) | Video attachment. NSE only. Used only when no image key is present |
attachments |
[String] or [{"url":"…"}] |
Multiple attachments (first 3). NSE only. Wins over all other image/video keys |
actions |
[{"id":"…","title":"…"}] |
Action buttons shown in the expanded notification. NSE only |
badge |
Int | Absolute badge count. NSE + App Group |
badge_increment |
Int | Delta added to running total. NSE + App Group. Wins over badge |
Every public method and property. Swift and ObjC side by side.
| Method / Property | Swift | Objective-C | Notes |
|---|---|---|---|
| Initialize | AppPushService.initialize(debug:swizzle:) |
[AOAPush initializeWithDebug:swizzle:] |
Call once at launch — registers device automatically |
| Set Listener | AppPushService.setListener(_:) |
[AOAPush setListener:] |
Receive token, notification, and error callbacks |
| Device ID | AppPushService.deviceId |
[AOAPush deviceId] |
Stable unique device identifier, persists across reinstalls |
| Subscription ID | AppPushService.subscriptionId |
[AOAPush subscriptionId] |
Set automatically after registration |
| Set Subscription ID | AppPushService.setSubscriptionId(_:) |
[AOAPush setSubscriptionId:] |
Manual override — not required for normal use |
| APNs Environment | AppPushService.apnsEnvironment |
[AOAPush apnsEnvironment] |
Sandbox or production |
| Auto-register APNs | AppPushService.autoRegisterForRemoteNotifications |
[AOAPush setAutoRegisterForRemoteNotifications:] |
Default: true |
| Request Permission | AppPushService.requestPermission() |
[AOAPush requestPermission] |
Shows the OS permission dialog |
| Is Permission Granted | await AppPushService.isPermissionGranted() |
[AOAPush isPermissionGrantedWithCompletion:] |
Returns current permission status |
| Login | AppPushService.login(_:) |
[AOAPush login:] |
Links this device to your user account |
| Logout | AppPushService.logout() |
[AOAPush logout] |
Unlinks user — clears tags and aliases, device stays registered |
| Consent Required | AppPushService.consentRequired |
[AOAPush setConsentRequired:] |
Set before initialize() |
| Consent Given | AppPushService.consentGiven |
[AOAPush setConsentGiven:] |
Set after user accepts your consent dialog |
| Silent Push | AppPushService.onSilentPushReceived |
[AOAPush handleSilentPush:fetchCompletionHandler:] |
Handle background silent pushes |
| Clear Notifications | AppPushService.clearAllNotifications() |
[AOAPush clearAllNotifications] |
Removes all delivered notifications from the tray |
| Badge Count | AppPushService.badgeCount |
[AOAPush badgeCount] |
Current badge number |
| Set Badge | AppPushService.setBadgeCount(_:) |
[AOAPush setBadgeCount:] |
Sets absolute value |
| Increment Badge | AppPushService.incrementBadgeCount(by:) |
[AOAPush incrementBadgeCountBy:] |
Delta (can be negative) |
| Clear Badge | AppPushService.clearBadgeCount() |
[AOAPush clearBadgeCount] |
Resets to 0 |
| Auto-clear Badge | AppPushService.autoClearBadgeOnForeground |
[AOAPush setAutoClearBadgeOnForeground:] |
Default: true |
| APNs Token (manual) | AppPushService.handleAPNsToken(_:) |
[AOAPush handleAPNsToken:] |
Required when swizzle: false |
| APNs Error (manual) | AppPushService.handleAPNsRegistrationError(_:) |
[AOAPush handleAPNsRegistrationError:] |
Required when swizzle: false |
| Will Present (manual) | AppPushService.handleWillPresent(notification:) |
[AOAPush handleWillPresentWithNotification:] |
Required when swizzle: false |
| Did Receive (manual) | AppPushService.handleDidReceive(response:) |
[AOAPush handleDidReceiveWithResponse:] |
Required when swizzle: false |
| Swift | Objective-C | |
|---|---|---|
| Log Level | AppPushService.Debug.logLevel |
[AOAPushDebug logLevel] / [AOAPushDebug setLogLevel:] |
| Method / Property | Swift | Objective-C |
|---|---|---|
| AppsOnAir ID | AppPushService.User.appsOnAirId |
[AOAPushUser appsOnAirId] |
| External ID | AppPushService.User.externalId |
[AOAPushUser externalId] |
| Language (read) | AppPushService.User.language |
[AOAPushUser language] |
| Set Language | AppPushService.User.setLanguage(_:) |
[AOAPushUser setLanguage:] |
| Add Tag | AppPushService.User.addTag(key:value:) |
[AOAPushUser addTagWithKey:value:] |
| Add Tags | AppPushService.User.addTags(_:) |
[AOAPushUser addTags:] |
| Remove Tag | AppPushService.User.removeTag(_:) |
[AOAPushUser removeTag:] |
| Remove Tags | AppPushService.User.removeTags(_:) |
[AOAPushUser removeTags:] |
| Get Tags (cache) | AppPushService.User.getTags() |
[AOAPushUser getTags] |
| Get Tags (backend) | AppPushService.User.getTags { … } |
[AOAPushUser fetchTagsFromBackendWithCompletion:] |
| Add Alias | AppPushService.User.addAlias(label:id:) |
[AOAPushUser addAliasWithLabel:id:] |
| Add Aliases | AppPushService.User.addAliases(_:) |
[AOAPushUser addAliases:] |
| Remove Alias | AppPushService.User.removeAlias(_:) |
[AOAPushUser removeAlias:] |
| Remove Aliases | AppPushService.User.removeAliases(_:) |
[AOAPushUser removeAliases:] |
| Get Aliases (cache) | AppPushService.User.getAliases() |
[AOAPushUser getAliases] |
| Get Aliases (backend) | AppPushService.User.getAliases { … } |
[AOAPushUser fetchAliasesFromBackendWithCompletion:] |
| Add Email | AppPushService.User.addEmail(_:) |
[AOAPushUser addEmail:] |
| Remove Email | AppPushService.User.removeEmail(_:) |
[AOAPushUser removeEmail:] |
| Opt Out | AppPushService.User.pushSubscription.optOut() |
[AOAPushUser optOut] |
| Opt In | AppPushService.User.pushSubscription.optIn() |
[AOAPushUser optIn] |
| Opted In | AppPushService.User.pushSubscription.optedIn |
[AOAPushUser pushSubscriptionOptedIn] |
| Sub Token | AppPushService.User.pushSubscription.token |
[AOAPushUser pushSubscriptionToken] |
| Sub ID | AppPushService.User.pushSubscription.id |
[AOAPushUser pushSubscriptionId] |
| Sub Observer | pushSubscription.addObserver(_:) |
[AOAPushUser addPushSubscriptionObserver:] |
| User Observer | AppPushService.User.addObserver(_:) |
[AOAPushUser addUserStateObserver:] |
| Method / Property | Swift | Objective-C |
|---|---|---|
| Request Permission | Notifications.requestPermission(fallbackToSettings:) |
[AOAPushNotifications requestPermissionWithFallbackToSettings:] |
| Provisional Auth | Notifications.registerForProvisionalAuthorization() |
[AOAPushNotifications registerForProvisionalAuthorization] |
| Permission | Notifications.permission |
[AOAPushNotifications permission] |
| Permission Native | Notifications.permissionNative |
[AOAPushNotifications permissionNative] |
| Can Request | Notifications.canRequestPermission |
[AOAPushNotifications canRequestPermission] |
| Refresh Permission | await Notifications.refreshPermission() |
[AOAPushNotifications refreshPermissionWithCompletion:] |
| Permission Observer | Notifications.addPermissionObserver(_:) |
[AOAPushNotifications addPermissionObserver:] |
| Remove Permission Observer | Notifications.removePermissionObserver(_:) |
[AOAPushNotifications removePermissionObserver:] |
| Lifecycle Listener | Notifications.addForegroundLifecycleListener(_:) |
[AOAPushNotifications addForegroundLifecycleListener:] |
| Remove Lifecycle Listener | Notifications.removeForegroundLifecycleListener(_:) |
[AOAPushNotifications removeForegroundLifecycleListener:] |
| Click Listener | Notifications.addClickListener(_:) |
[AOAPushNotifications addClickListener:] |
| Remove Click Listener | Notifications.removeClickListener(_:) |
[AOAPushNotifications removeClickListener:] |
| Clear All | Notifications.clearAllNotifications() |
[AOAPushNotifications clearAllNotifications] |
| Remove by ID | Notifications.removeNotification(withIdentifier:) |
[AOAPushNotifications removeNotificationWithIdentifier:] |
| Remove by IDs | Notifications.removeNotifications(withIdentifiers:) |
[AOAPushNotifications removeNotificationsWithIdentifiers:] |
Schedules and manages the background sync task.
| Method / Property | Swift | Objective-C |
|---|---|---|
| Register Handlers | AppsOnAirBackgroundSync.registerHandlers() |
[AOAPushBackgroundSync registerHandlers] |
| Schedule | AppsOnAirBackgroundSync.scheduleIfNeeded(minimumDelay:) |
[AOAPushBackgroundSync scheduleIfNeededWithMinimumDelay:] |
| Cancel | AppsOnAirBackgroundSync.cancelPending() |
[AOAPushBackgroundSync cancelPending] |
| Task ID | AppsOnAirBackgroundSync.taskIdentifier |
[AOAPushBackgroundSync taskIdentifier] |
| Swift | Objective-C | |
|---|---|---|
| Base class | AppsOnAirContentViewController |
AOAContentViewController |
| Override hook | configure(with notification: UNNotification) |
configureWithNotification: |
| Renders | Full-width image · bold title · multiline body | Same |
| ObjC subclassable | No (objc_subclassing_restricted) |
Yes |
Use inside a Notification Service Extension target only. Handles media downloads, text overrides, badge updates, and delivery analytics automatically.
| Method | Swift | Objective-C |
|---|---|---|
| Did Receive | AppPushServiceExtension.didReceiveNotificationExtensionRequest(_:with:withContentHandler:) |
[AOAPushExtension didReceiveNotificationRequest:withContentHandler:] |
| Time Will Expire | AppPushServiceExtension.serviceExtensionTimeWillExpireRequest(_:with:) |
[AOAPushExtension serviceExtensionTimeWillExpireRequest:bestAttemptContent:contentHandler:] |
Assign a listener via AppPushService.setListener(_:) to receive APNs token updates, foreground notifications, opens, and errors. In ObjC all four methods are @optional; in Swift implement all four (empty body is fine).
public protocol PushListener: AnyObject {
func onAPNsTokenUpdated(token: String, environment: APNsEnvironment)
func onNotificationReceived(notification: PushNotification)
func onNotificationOpened(notification: PushNotification)
func onError(_ error: PushError)
}@protocol AOAPushListener <NSObject>
@optional
- (void)onAPNsTokenUpdatedWithToken:(NSString *)token
environment:(AOAAPNsEnvironment)environment;
- (void)onNotificationReceivedWithNotification:(AOAPushNotification *)notification;
- (void)onNotificationOpenedWithNotification:(AOAPushNotification *)notification;
- (void)onError:(NSError *)error;
@endIn Swift all four must be implemented (empty body is fine). In ObjC all four are @optional.
Errors come through onError in PushListener. Swift gets a PushError enum; ObjC gets an NSError with domain AppPushServiceErrorDomain.
| Code | Meaning | Fix |
|---|---|---|
notInitialized |
SDK method called before initialize() |
Call initialize() first |
permissionDenied |
User denied notification permission | Guide user to Settings → Notifications |
apnsRegistrationFailed |
APNs registration failed | Verify Push Notifications capability |
unknown |
Unexpected error | Check the error message for details |
APNs tokens don't exist on simulator. Once the user grants permission, the SDK fires:
onAPNsTokenUpdated(token: "SIMULATOR-<deviceId>", environment: .sandbox)
This mock token works with all other SDK calls so you can test your registration flow without a device. If permission is denied, onError fires with permissionDenied.
Most issues are a missing capability, wrong Info.plist key, or wrong call order. Enable verbose logging first — AppPushService.Debug.logLevel = .verbose — then check below.
| Problem | Likely cause | Fix |
|---|---|---|
| No APNs token | Push Notifications capability missing | Signing & Capabilities → + → Push Notifications |
| NSE not invoked | mutable-content: 1 absent from payload |
Add it to every push |
| NSE not invoked | NSE deployment target higher than device iOS version | iOS silently refuses to launch any extension whose minimum OS version exceeds the device OS — no crash, no log, the extension is never invoked. In Xcode: NSE target → Build Settings → iOS Deployment Target → set to 15.0 (or match your main app's minimum) |
| NSE not invoked | App Group missing or mismatched | Add same group to main app AND NSE via Signing & Capabilities; then manually add AppsOnAirAppGroup to both Info.plists (Xcode does not do this automatically) |
| APNs token / permission not showing | AppsOnAirAppGroup missing from main app Info.plist |
Adding App Group in Signing & Capabilities only updates .entitlements — you must also add AppsOnAirAppGroup to Info.plist manually |
| No Delivered analytics | Same as NSE not invoked | Check above |
| Background sync never fires | Handler registered too late | Call registerHandlers() before any scene connects |
BGTaskScheduler error |
Missing Info.plist entry | Add com.appsonair.push.background-sync to BGTaskSchedulerPermittedIdentifiers |
| Badge not updating | NSE not running | Verify mutable-content: 1 and App Group are configured |
ObjC compile error objc_subclassing_restricted |
Trying to subclass AppsOnAirNotificationServiceExtension or AppsOnAirContentViewController from ObjC |
NSE: use AOAPushExtension static methods. CE: subclass AOAContentViewController instead — see ObjC setup in each section |
| Xcode warning: "Extension version must match parent app" | CURRENT_PROJECT_VERSION (CFBundleVersion) differs between your main app and extension targets |
In Xcode Build Settings, set the same CURRENT_PROJECT_VERSION value on all extension targets (NSE, CE) as the main app target |
| App Store / Xcode warning: "All interface orientations must be supported unless the app requires full screen" | UISupportedInterfaceOrientations not declared in Info.plist |
Add UISupportedInterfaceOrientations (iPhone) and UISupportedInterfaceOrientations~ipad (iPad — all 4 orientations) to your main app Info.plist |
CFPrefsPlistSource warning in console on real device |
App Group not included in the Development provisioning profile | Regenerate both the main app and NSE provisioning profiles in Apple Developer Portal after adding the App Group to each App ID. CE profile does not need the App Group |
| Silent push: APNs returns 200 OK but device never receives it | Wrong apns-push-type / apns-priority headers |
Silent push requires apns-push-type: background + apns-priority: 5. Alert push requires apns-push-type: alert + apns-priority: 10. Mixing them causes iOS to silently drop the notification |
Silent push: onSilentPushReceived never fires |
Background App Refresh is OFF | Enable: iPhone Settings → General → Background App Refresh → Wi-Fi & Cellular Data |
Silent push: onSilentPushReceived never fires |
Low Power Mode is ON | iOS suspends silent push entirely in Low Power Mode. Disable: iPhone Settings → Battery → Low Power Mode |
Silent push: onSilentPushReceived never fires |
App was force-killed | iOS will not wake a force-killed app for silent push. Background it by pressing Home — do not swipe up in App Switcher |