Repository navigation
feat(apple): background push through APNs - #2000
ArnabChatterjee20k wants to merge 27 commits into
Conversation
Subscribing with background: true now also registers the app with APNs and creates the device's push target and topic subscribers, so messages reach a suspended or killed app as visible notifications. Push installs a proxy on the app delegate to receive the device token and remote notifications; apps that opt out with AppwritePushAutoRegistration = NO call Push.setDeviceToken and Push.handleRemoteNotification themselves. A message that arrives through both APNs and MQTT is delivered once, deduplicated by messageId, and Push.isDelivered tells a custom notification delegate whether one was already shown.
🟢 Tier S · Ready to mergeThis PR adds APNs-backed background delivery to the generated Apple Push SDK, including push-target and topic-subscriber management, shared app/extension state, and MQTT message handoff with deduplication. It also generates a notification service extension and tests for push handling, delegate behavior, and shared delivery state. Latest changes: The newest commits display raw non-object payloads as notification bodies, limit envelope banners to nonempty notification titles or bodies, and add tests for the visibility classification.
Note Not approving while a bug finding is open: Reconcile persistent subscribers after app relaunch 📂 Walkthrough · 4
Reviewed the commits since |
The delegate proxy was installed, and the push registered for wake-ups, only after the SUBSCRIBE finished. A wake push arriving on a fresh launch while it was in flight went to no one. Both now happen before the subscription's network work; asking APNs for a token still waits for a successful subscribe.
willPresent answered Appwrite's pushes itself, so an app's own userNotificationCenter(_:willPresent:) stopped running for them. It is now called for those pushes too, and its options are used unless MQTT already delivered the message.
…shes too The delegate proxy returned early for Appwrite's pushes, so an app's own application(_:didReceiveRemoteNotification:fetchCompletionHandler:) stopped seeing them. Both now run, and iOS gets a single completion once both finish: new data when either had some.
register linked the topics it started with after awaiting the target request. A topic unsubscribed meanwhile got a subscriber anyway, since apnsDisable found nothing linked yet to remove. Topics are checked again before each link, and a subscriber created for a topic that was unsubscribed while the request was in flight is removed.
… target The target id is stable per installation and user, so the same install moving from a debug build to TestFlight hits the 409 path. That path only updated the token, leaving the sandbox environment on the target. The update now sends the environment and bundle id as well.
The findings that requested changes are resolved. See the summary comment for what is still open.
SwiftUI's app delegate implements none of the remote-notification methods; it forwards them to the @UIApplicationDelegateAdaptor class. The proxy added its methods to SwiftUI's class, so the app's own didRegisterForRemoteNotifications and didReceiveRemoteNotification stopped running. A method the delegate forwards is now proxied on the class it forwards to. Found running a SwiftUI app in the iOS Simulator; covered by a test with a forwarding delegate.
The SDK is also built on Linux, where ObjectiveC, CryptoKit, Security and UIKit do not exist, and import ObjectiveC failed the apple and AppleSwift61 jobs. The import is now guarded, and the app delegate proxy, which needs the Objective-C runtime, moves to an extension that exists only where the runtime does; elsewhere its two entry points do nothing, as there is no APNs there. The delegate proxy tests are guarded the same way.
A topic put back into a background subscription while its subscriber delete was in flight was skipped by link, which still saw it linked; the delete then removed the subscriber and the topic lost APNs delivery. After the delete, unlink links the topic again when a background subscription still has it.
… Simulator builds as sandbox appwrite/appwrite#14302 puts the wake marker under a top-level data key beside aps, which Push did not read, so it treated the wake as another service's push. It now reads messageId and type under data as well as under appwrite, aps.data or at the top level; a data key without a messageId is still not Appwrite's. A Simulator build has no provisioning profile, so its environment fell back to production, but Simulator device tokens are always sandbox ones and the target pointed the server at the wrong APNs provider. Simulator builds now register as sandbox.
A silent push never reaches an app the user swiped away from recents, but a visible push with mutable-content always runs the app's Notification Service Extension. AppwriteNotificationService is that extension's base class: for a placeholder push it connects over MQTT with the app's session, takes the backlog the broker replays, and shows one message in the placeholder's place, queueing the rest for the pushes that follow. No content passes through APNs. The app and the extension share, through the App Group named by AppwritePushAppGroup: the push session and its credential, the delivered-message store, a heartbeat while the app is connected, and the messages the extension fetched. While the app is live the extension does not connect; it replaces the app's latest banner instead, which Push now posts under the message id. On its next start Push delivers what the extension fetched to the subscription callbacks without banners.
The project header is added by each generated service call, not kept on the client, so the raw target requests reached the server without it and were refused as an unknown origin.
The project header is added by each generated service call, not kept on the client, so the raw target requests reached the server without it and were refused as an unknown origin.
A killed app keeps a fresh heartbeat for a few seconds, so the extension took it as live and left the placeholder in place without fetching. It now fetches whenever there is no recent app banner to replace.
The extension filled later placeholders from messages it had queued even after the app had taken them over, showing stale content in place of the new message. The app clears the queue when it takes the hand-off, and entries older than 30 seconds are dropped.
…keep queued banners An NSLock only serializes one process, so the app and its extension could overwrite each other's hand-offs and delivered ids. Shared state now also takes a file lock in the App Group container. Clearing the queue on hand-off left the placeholders that follow without their content when the app opened first. Queued messages are kept, used before the live-app check, and dropped after 30 seconds.
feat(apple): deliver push through a Notification Service Extension
Push called Messaging(client), which isn't generated when the spec has no client Messaging service, as in the e2e fixture, so the Apple SDK failed to compile there. Topic links now call the API directly with the project header, like target registration.
…ption is left close() and dropping the last background subscription left the device's target and the session shared with the extension in place, so after a sign-out the server kept pushing and the extension fetched the old user's messages. Push now deletes the target and clears the shared session; switching user registers the new user's target.
A message without a notification block posted a banner with its raw JSON as the body. It now only reaches the callbacks, and the extension shows a message that has content.
With the broker down, the session gone or only data-only messages, the extension leaves the placeholder in place. It marks it, and the app removes the marked placeholders once it is connected and has the messages.
… needs it The target is shared by every Push of the user, so deleting it on one Push's last background subscription could stop delivery for another, or for a subscription added meanwhile. The delete now checks every Push of the user right before it runs, and the shared session is kept from one that still has background subscriptions.
The app cleared every unfilled placeholder on its first subscribe, even when the topic the placeholder was for was not subscribed yet. The extension now records the topics it fetched for, and the app clears a placeholder once all of them are subscribed, so the broker replays its message to a callback.
…ssion Without a session the extension fetched for no topic, so the empty mark let the app clear the placeholder on any subscribe, with no message behind it reaching a callback.
…ext only Matches #2013 on the other SDKs: a payload that is not a JSON object is shown with itself as the body, and an envelope is shown only when its notification has a title or body. An empty or image-only notification block no longer posts a title-only banner, and a raw MQTT payload gets one again.
What does this PR do?
Delivers Appwrite Push to an iOS app that is in the background, terminated, or swiped away, with the message content still going over MQTT, never through Apple. It includes #2012 (the Notification Service Extension), merged into this branch.
How to use it
1. Set up the project
This uploads the APNs key, creates the APNs providers (sandbox and production), registers the App ID with Push Notifications, and sets
aps-environmentin the app's entitlements. The project also needs the Appwrite push provider (MQTT) enabled.2. Subscribe in the app
Nothing new to call. A subscription with
background: true(the default) also registers the device with APNs:Taps arrive through the existing API:
push.onNotificationOpened { … }andpush.getInitialNotification().3. Add a Notification Service Extension
This is what lets messages reach an app that isn't running. Without it, the user sees the placeholder ("New message") and gets the content when the app opens.
4. Sign out
Call
close()before ending the session:Once a user has no background subscription left,
Pushdeletes the device's push target and clears the session it shares with the extension. The server stops pushing to the device, and the extension can't fetch as that user. Signing in as someone else and subscribing registers the device for the new user.Optional: handle the delegate yourself
Pushproxies the app delegate to get the device token and remote notifications, and still calls the app's own methods. To turn that off, setAppwritePushAutoRegistration = NOin Info.plist and forward the calls:How it works
Pushregisters for remote notifications and creates the push target,POST /account/targets/push. It updates the target on409, and sendsenvironment(sandboxfor Simulator and development builds) andbundleId. The target ID is stable per installation and user.Pushdoesn't need a generated Messaging service.users/<id>needs none. Subscribers are removed once no background subscription still uses the topic.NSLockand a file lock in the group container, because the app and the extension are separate processes.messageId, up to 500, kept across launches and shared with the extension.notificationblock) reach the callbacks with no banner.Server dependencies (appwrite/appwrite#14302)
The placeholder push is in #14302. Still needed there:
appwriteprovider, which the SDK never creates, so the message goes out as a full APNs alert.bundleIdandenvironment. Today the target uses the default push provider, so Simulator and development builds land on production.Not in this PR
appwrite init apnsadding the extension target and App Group (step 3 is manual for now).Test Plan
21 Swift Testing tests (
PushTests.swift) covering:swift buildon macOS, the iOS Simulator SDK, and Linux (swift:6.1-jammy, the CI image, from the e2e fixture spec);swift format lint --strict; Rector, Twig lint and the Generation suite.iOS Simulator against a real local Appwrite (#14302 branch), the real MQTT broker and the real APNs sandbox:
Swipe-away from the App Switcher, against the mock broker.
Swipe-away against the real stack, tapping a banner, and notifications turned off.
Real device: the extension's memory and time limits.
Push edge-case runbook
The Android runbook's cases (27 device scenarios), run on iOS against a real local Appwrite, its MQTT broker and the APNs sandbox.
SDK findings from the runbook
3. A data-only push posted a notification with the raw JSON as its body. The same bug was in the Apple SDK. Fixed in 275a593 with the rule fix(push): post no notification for data-only pushes #2013 uses on the other SDKs:
notificationhas a title or body;Tested on the Simulator with the app open and terminated.
15. Battery "Restricted" not reported by
backgroundStatus()(fix(push): report restricted background usage as best effort #2014). Not applicable: it's an Android setting, and the Apple SDK has nobackgroundStatus().Server findings: 11 (stuck replay cursor), 21 (expired messages replayed) and 24 (open connection of a blocked user). They affect iOS the same way, but nothing in the SDK can fix them. They're fixed by appwrite/appwrite#14410, #14412 and #14411.
Runbook cases run on iOS
Related PRs and Issues
appwrite init apns)