Skip to content

feat(apple): deliver push through a Notification Service Extension - #2012

Merged
ArnabChatterjee20k merged 6 commits into
feat/apple-push-apnsfrom
feat/apple-push-service-extension
Oct 13, 2026
Merged

ArnabChatterjee20k merged 6 commits into
feat/apple-push-apnsfrom
feat/apple-push-service-extension

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Delivers push to an iOS app that was swiped away from recents, without the message content passing through APNs.

A silent push (#2000, appwrite/appwrite#14302) wakes a suspended app, but iOS never launches an app the user swiped away: "the system does not automatically launch your app if the user has force-quit it" (Apple). A visible push with mutable-content: 1 always shows, and iOS runs the app's Notification Service Extension for it first, even after a swipe-away. This PR makes that extension a one-line class:

  • APNs carries a placeholder only.
  • The extension fetches the real message over MQTT with the app's session, and shows it in the placeholder's place.
APNs { aps: { alert: { title: "New message" }, mutable-content: 1 } }    no content, no id
  → AppwriteNotificationService runs (even if the app was swiped away)
      app live?  yes → replace the app's latest banner with this one   (one banner)
                 no  → connect MQTT with the app's session → broker replays the backlog
                       → show one, queue the rest, hand all to the app
  → iOS shows the banner

Usage

  1. Add a Notification Service Extension target:
    import Appwrite
    
    class NotificationService: AppwriteNotificationService {}
  2. Put the app and the extension in one App Group, and set it in both Info.plists:
    <key>AppwritePushAppGroup</key>
    <string>group.com.example.app</string>
  3. Nothing else changes in the app: push.subscribe(…, background: true) as before.

How

  • Shared session (App Group):
    • Push keeps the endpoint, push endpoint, project, client id, and background topics with their titles, while it has background subscriptions.
    • The session secret or JWT goes in the Keychain, under the App Group's access group.
  • No duplicate content:
    • The broker delivers each message once per session (user + client id).
    • The app and the extension share that session and the delivered-message store, which now re-reads the shared store, since two processes write it.
  • App live: Push stamps a heartbeat every 3 s while connected; a suspended app stops. While the stamp is fresh (< 10 s), the extension doesn't connect, since the session allows one connection. Instead it replaces the app's latest banner, which Push now posts under the message id and marks as its own, so the user sees one banner.
  • Several waiting messages: the extension shows one per push, and queues the rest for the pushes that follow.
  • Hand-off: on its next subscribe, Push delivers the messages the extension fetched to the matching callbacks without banners, since iOS already showed them.
  • Time limit: stops collecting once the replay goes quiet (1.5 s), or after 20 s. On iOS's ~30 s limit, the placeholder shows.

Server

The SDK side works with any visible mutable-content push. The server half is on appwrite/appwrite#14302, still to do:

  • Send the placeholder visible push to Apple devices instead of (or as well as) the silent wake.
  • Send it only when that device's session hasn't acknowledged the message after a short wait, so a live app gets no push at all. The banner replacement here covers the race when the acknowledgement is late.

Test Plan

  • Unit tests (20 in total, 5 new):
    • shared session round-trip;
    • heartbeat window;
    • hand-off by topic, taken once;
    • pending queue order;
    • two delivered-message stores, standing in for the app and the extension, seeing each other's writes.
  • swift build on macOS, iOS Simulator, and Linux (Swift 6.1, the CI image); swift format lint --strict.
  • Rector, Twig lint, Generation suite.
  • iOS Simulator, real APNs sandbox placeholder pushes, against the mock MQTT broker. SpringBoard logged "Service extension delivered mutated notification content" in each case:
    • App suspended: the extension fetched the message and the banner showed it in 2.5 s; the app logged nothing.
    • App swiped away (terminated): the same, with the app not running.
    • App swiped away from the App Switcher by hand, the user force-quit that blocks silent pushes: the extension still ran and the banner showed the fetched message (apns-unique-id 812409cc-…), with no app process running.
    • App relaunched: the two messages the extension fetched reached the callback with no banner, before the broker's live message, which got its banner.
    • App open: the extension didn't connect. SpringBoard logged "Removing delivered notifications" for the app's banner, and the push's banner showed that message instead.
  • Real device. Visible pushes and mutable-content work in the Simulator, unlike silent pushes, so nothing here is Simulator-only, but the extension's memory and time limits should be checked on a device.

Related PRs and Issues

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.
@hansi-codes

hansi-codes Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

Adds an Apple Notification Service Extension base class that fetches pending push messages over MQTT and substitutes them into visible placeholder notifications. It also persists shared session, heartbeat, pending-message, and handoff state for coordination between the app and extension, and updates the Apple SDK generator and tests accordingly.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 4
File Change
src/SDK/Language/Apple.php Registers the new generated Notification Service Extension source file.
templates/apple/Sources/Services/Push.swift.twig Adds heartbeat and shared handoff integration, and stores delivered-message state in shared defaults.
templates/apple/Sources/Services/PushServiceExtension.swift.twig Implements shared state, credential storage, MQTT backlog fetching, and notification-content replacement.
templates/apple/Tests/PushTests.swift.twig Adds tests for shared session state, heartbeat, handoff, queue behavior, and cross-store delivery deduplication.
🔇 Filtered out · 1

Findings Hansi considered but did not post.

Finding Why
Use a Keychain Sharing group for the shared credential Verifier: The premise is incorrect for iOS: groups granted through the com.apple.security.application-groups entitlement are also valid keychain access groups. Both targets enabling the same App Group, as required by the documentation at lines 249–250, permits using its identifier directly as kSecAttrAccessGroup at line 232; a separate Keychain Sharing group is not required. Ignoring SecItemAdd's status does not establish the claimed entitlement failure under that setup.

Reviewed 0565e9a · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread templates/apple/Sources/Services/PushServiceExtension.swift.twig
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.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · See the inline comments. Summary

Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
…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.
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@ArnabChatterjee20k
ArnabChatterjee20k merged commit 6a5f51b into feat/apple-push-apns Oct 13, 2026
57 of 61 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant