From 33318a383ac53a31d9798d577a04da56e04235aa Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 22 Jan 2026 17:59:34 +0100 Subject: [PATCH 1/5] Add getting started guide for APNs (with location pushes) for swift. --- src/data/nav/pubsub.ts | 9 + src/pages/docs/push/getting-started/apns.mdx | 1142 ++++++++++++++++++ 2 files changed, 1151 insertions(+) create mode 100644 src/pages/docs/push/getting-started/apns.mdx diff --git a/src/data/nav/pubsub.ts b/src/data/nav/pubsub.ts index 649d00dd04..a074e1d1c9 100644 --- a/src/data/nav/pubsub.ts +++ b/src/data/nav/pubsub.ts @@ -239,6 +239,15 @@ export default { link: '/docs/push', index: true, }, + { + name: 'Getting started', + pages: [ + { + name: 'APNs', + link: '/docs/push/getting-started/apns', + }, + ], + }, { name: 'Configure and activate', pages: [ diff --git a/src/pages/docs/push/getting-started/apns.mdx b/src/pages/docs/push/getting-started/apns.mdx new file mode 100644 index 0000000000..0ca8364c38 --- /dev/null +++ b/src/pages/docs/push/getting-started/apns.mdx @@ -0,0 +1,1142 @@ +--- +title: "Getting started: Push Notifications in Swift" +meta_description: "Get started with Ably Push Notifications in Swift. Learn how to register for push notifications, activate push on your client, handle incoming notifications, and send push messages." +meta_keywords: "Push Notifications Swift, Ably Push, APNs, iOS Push, Swift push notifications, Ably Push Notifications guide, realtime push Swift, push notification example, Ably tutorial Swift, device registration, push messaging" +--- + +This guide will get you started with Ably Push Notifications in a new SwiftUI application. + +You'll learn how to set up your `AppDelegate` to manage push notifications, register devices with Ably, send push notifications, subscribe to channel-based push, handle incoming notifications, and implement location-based push notifications. + +## Prerequisites + +1. [Sign up](https://ably.com/signup) for an Ably account. +2. Create a [new app](https://ably.com/accounts/any/apps/new), and create your first API key in the **API Keys** tab of the dashboard. +3. Your API key will need the `publish` and `subscribe` capabilities. For sending push notifications from your app, you'll also need the `push-admin` capability. +4. Install [Xcode](https://developer.apple.com/xcode/). +5. You'll need a real iOS device to test push notifications (the simulator doesn't support APNs). +6. Set up Apple Push Notification service (APNs) certificates through the [Apple Developer Portal](https://developer.apple.com/). + +### Set up APNs certificates + +To enable push notifications, you need to configure APNs on Apple's developer portal: + +1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/certificates/list). +2. Create an App ID for your application (if you don't have one already). +3. Enable the Push Notifications capability for your App ID. +4. Create an APNs certificate and download it. +5. In the Ably dashboard, navigate to your app's **Notifications** tab. +6. Scroll to the **Push Notifications Setup** section and select **Configure Push**. +7. Follow the instructions to upload your APNs certificate. + +### Create a Swift project with Xcode + +Create a new iOS project with SwiftUI. For detailed instructions, refer to the [Apple Developer documentation](https://developer.apple.com/documentation/swiftui). + +1. Create a new iOS project in Xcode. +2. Select **App** as the template +3. Name the project **PushTutorial** and set the bundle identifier (such as `your.company.PushTutorial`) +4. Set the minimum iOS deployment target to iOS 15.0 or higher +5. Select SwiftUI as the interface and Swift as the language +6. Add the Ably SDK dependency to your project: + + - In Xcode, go to **File > Add Package Dependencies** + - Enter the repository URL: `https://github.com/ably/ably-cocoa` + - Select the latest version and add it to your target + +Update your project settings: + +1. Select your project in Xcode's Project Navigator. +2. Select the target for your app. +3. Go to the **Signing & Capabilities** tab. +4. Make sure you've selected your development team and that a provisioning profile has been created. +5. Add the **Push Notifications** capability by clicking **+ Capability**. + +All code can be added directly to your `ContentView.swift` and `AppDelegate.swift` files. + +## Step 1: Setting up Ably + +Create `AppDelegate.swift` file and add the `AppDelegate` class which should conform to the following protocols: `UIApplicationDelegate`, `ARTPushRegistererDelegate`, `UNUserNotificationCenterDelegate`, and `CLLocationManagerDelegate`. + +Set Ably realtime client, notification center, and location manager in your +`application:didFinishLaunchingWithOptions` delegate method as shown below: + + +```swift +import Ably +import UIKit +import CoreLocation +import UserNotifications + +class AppDelegate: NSObject, UIApplicationDelegate, ARTPushRegistererDelegate, UNUserNotificationCenterDelegate, CLLocationManagerDelegate { + + // MARK: - Properties + var realtime: ARTRealtime! + var locationManager: CLLocationManager! + + var defaultDeviceToken: String? + var locationDeviceToken: String? + + var activatePushCallback: ((String, ARTErrorInfo?) -> ())? + var activateLocationPushCallback: ((String, ARTErrorInfo?) -> ())? + var locationGrantedCallback: ((Bool) -> ())? + + // MARK: - UIApplicationDelegate Methods + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + // Initialize Ably Realtime client with push registerer delegate + let clientOptions = ARTClientOptions(key: "{{API_KEY}}") + clientOptions.clientId = "push-tutorial-client" + clientOptions.pushRegistererDelegate = self + realtime = ARTRealtime(options: clientOptions) + + // Set up notification delegate + UNUserNotificationCenter.current().delegate = self + + // Setup location manager for location-based push + locationManager = CLLocationManager() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + + return true + } +} +``` + + +Here you also have some properties defined to manage device tokens and callbacks for UI which we'll use later. + +## Step 2: Setting up push notifications + +To send and receive push notifications you need to provide `ably-cocoa` with the device token received +from Apple in the `application:didRegisterForRemoteNotificationsWithDeviceToken` delegate method. +You also need to request notification permissions from the user and register your device with Ably. +To handle registration results, you'll implement the `ARTPushRegistererDelegate` methods. +Getting device details is also useful to confirm that your device is registered correctly. + +Append the following code to your `AppDelegate` class: + + +```swift +func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + defaultDeviceToken = deviceToken.map { String(format: "%02x", UInt($0)) }.joined() // Convert device token data to a hex string + print("Device Token registered: \(defaultDeviceToken!)") + // Use Ably's global ARTPush method to register the device token with Ably + ARTPush.didRegisterForRemoteNotifications(withDeviceToken: deviceToken, realtime: realtime) +} + +func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("Failed to register for remote notifications: \(error.localizedDescription)") + // Use Ably's global ARTPush method to handle registration failure + ARTPush.didFailToRegisterForRemoteNotificationsWithError(error, realtime: realtime) +} + +// MARK: - Push Notifications Methods + +/// Request notification permissions from user +func requestUserNotificationAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + DispatchQueue.main.async { + if granted { + print("Notification permissions granted") + } else if let error = error { + print("Notification permission error: \(error.localizedDescription)") + } + } + } +} + +/// Activate push notifications +func activatePushNotifications(_ callback: @escaping (String, ARTErrorInfo?) -> ()) { + activatePushCallback = callback + // Request notification permissions + requestUserNotificationAuthorization() + // Activate push notifications with Ably + realtime.push.activate() + print("Activating push notifications...") +} + +/// Deactivate push notifications +func deactivatePush() { + realtime.push.deactivate() + print("Deactivating push notifications...") +} + +/// Get current device registration details +func getDeviceDetails(_ callback: @escaping (ARTDeviceDetails?, ARTErrorInfo?) -> ()) { + realtime.push.admin.deviceRegistrations.get(realtime.device.id, callback: callback) +} + +// MARK: - ARTPushRegistererDelegate Methods + +func didActivateAblyPush(_ error: ARTErrorInfo?) { + print("Push activation: \(error?.localizedDescription ?? "Success")") + if let defaultDeviceToken { + // Notify UI about activation result + activatePushCallback?(defaultDeviceToken, error) + } +} + +func didDeactivateAblyPush(_ error: ARTErrorInfo?) { + print("Push deactivation: \(error?.localizedDescription ?? "Success")") +} +``` + + +## Step 3: Receiving push notifications + +Use `UNUserNotificationCenterDelegate` methods to receive push notifications. +You've set the notification center delegate in the `application:didFinishLaunchingWithOptions` method. + +Add these methods to your `AppDelegate` class: + + +```swift +// MARK: - UNUserNotificationCenterDelegate Methods + +/// Handle notification when app is in foreground +func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + print("Notification received in foreground: \(userInfo)") + + // Display notification with banner, sound, and badge + completionHandler([.banner, .sound, .badge]) +} + +/// Handle notification when user taps on it +func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + print("Notification tapped: \(userInfo)") + + completionHandler() +} +``` + + +To receive messages on the channel as push notifications, you need to subscribe to the push notifications +in the channel first: + + +```swift +// MARK: - Subscribe to Channels + +/// Subscribe to a channel for push notifications +func subscribeToChannel(_ channelName: String) { + let channel = realtime.channels.get(channelName) + + channel.push.subscribeDevice { error in + if let error = error { + print("Error subscribing to channel push: \(error.localizedDescription)") + } else { + print("Subscribed to push notifications on channel: \(channelName)") + } + } +} + +/// Unsubscribe from a channel +func unsubscribeFromChannel(_ channelName: String) { + let channel = realtime.channels.get(channelName) + + channel.push.unsubscribeDevice { error in + if let error = error { + print("Error unsubscribing from channel push: \(error.localizedDescription)") + } else { + print("Unsubscribed from push notifications on channel: \(channelName)") + } + } +} +``` + + +To test this code, you can send push notifications from the Ably dashboard or using the API (see the next step). + +Alternatively, you can use Ably CLI to send push notifications. +First, [install](https://github.com/ably/cli) Ably CLI. +Then, use the following command to send a push notification using a client ID (or a device ID): + + +```shell +ably push publish --client-id push-tutorial-client \ + --title "Test push" \ + --body "Hello from CLI!" \ + --data '{"foo":"bar","baz":"qux"}' +``` + + +Or to a channel: + + +```shell +ably channels publish --api-key "{{API_KEY}}" exampleChannel1 '{"data":{"foo":"bar","baz":"qux"},"extras":{"push":{"notification":{"title":"Test push","body":"Hello from CLI!"}}}}' +``` + + +You need to login to the Ably CLI before running this command: + + +```shell +ably login +``` + + + +## Step 4: Sending push notifications + +In order to send push notifications from the app you need to add `push-admin` capability for your API Key +in the Ably dashboard. Usually, push notifications are sent from your backend server, but for demonstration +purposes you can send them directly from the app using the Ably Realtime client's Push Admin API. + +Add the following methods to your `AppDelegate` class: + + +```swift +// MARK: - Send Push Notifications + +/// Send push notification to a specific device ID +func sendPushToDevice() { + let recipient = [ + "deviceId": realtime.device.id + ] + let data = [ + "notification": [ + "title": "Push Tutorial", + "body": "Hello from device ID!" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + realtime.push.admin.publish(recipient, data: data) { error in + print("Publish result: \(error?.localizedDescription ?? "Success")") + } +} + +/// Send push notification to a specific client ID +func sendPushToClientId() { + let recipient = [ + "clientId": realtime.auth.clientId ?? "push-tutorial-client" + ] + let data = [ + "notification": [ + "title": "Push Tutorial", + "body": "Hello from client ID!" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + realtime.push.admin.publish(recipient, data: data) { error in + print("Publish result: \(error?.localizedDescription ?? "Success")") + } +} +``` + + +You can also send push notifications by publishing a message with a `push` message's `extras` field to a specific channel: + +```swift +/// Send push notification to a specific channel by publishing a message with a push extras field +func sendPushToChannel(_ channelName: String) { + let message = ARTMessage(name: "example", data: "Hello from channel!") + message.extras = [ + "push": [ + "notification": [ + "title": "Channel Push", + "body": "Sent push to \(channelName)" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + ] as any ARTJsonCompatible + + realtime.channels.get(channelName).publish([message]) { error in + if let error { + print("Error sending push to \(channelName) with error: \(error.localizedDescription)") + } else { + print("Sent push to \(channelName)") + } + } +} +``` + + +You don't need admin capabilities to send push notifications to a channel. + +## Step 5: Location pushes + +Starting from iOS 15 you can efficiently receive location requests as push notifications. +For that you need to apply for the special entitlement on the [Apple Developer Portal](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_location_push). + +Add `Location (when in use)`, `Location (Always)`, `Location Push Service Extension`, and +`Push Notifications` capabilities to the **Signing & Capabilities** tab in the Xcode project target settings. + +Add `Location Push Service Extension` target as described at the [Apple Developer Portal](https://developer.apple.com/documentation/CoreLocation/creating-a-location-push-service-extension). +For simplicity, use **Automatically manage signing**, so all needed identifiers are created for you by +Xcode (with XC prefix in their display name). Your Location Push Service Extension should have a bundle +identifier of your app with a suffix of extension's product name (e.g., `the.company.TheApp.TheExtension`). + +Add these methods to your `AppDelegate` class: + + +```swift +// MARK: - Location push methods + +/// Enable location push monitoring +func enableLocationPush(grantedCallback: @escaping (Bool) -> (), tokenCallback: @escaping (String, ARTErrorInfo?) -> ()) { + // Store callbacks since location permission request is asynchronous + locationGrantedCallback = grantedCallback + activateLocationPushCallback = tokenCallback + + switch locationManager.authorizationStatus { + case .authorizedAlways: + // Location permissions already granted + locationGrantedCallback?(true) + // Activate location push monitoring + activateLocationPush() + print("Location push enabled") + case .notDetermined: + // Request location permissions from user with 'Always' authorization needed for location pushes + locationManager.requestAlwaysAuthorization() + case .denied, .restricted, .authorizedWhenInUse: + locationGrantedCallback?(false) + print("Location permission denied or restricted") + @unknown default: + break + } +} + +/// Disable location push monitoring +func disableLocationPush() { + locationManager?.stopUpdatingLocation() + print("Location push disabled") +} + +/// Activate location push monitoring +func activateLocationPush() { + print("Starting monitoring location pushes...") + locationManager.startMonitoringLocationPushes { deviceToken, error in + guard error == nil else { + return ARTPush.didFailToRegisterForLocationNotificationsWithError(error!, realtime: self.realtime) + } + if let deviceToken { + // Convert device token data to a hex string + self.locationDeviceToken = deviceToken.map { String(format: "%02x", UInt($0)) }.joined() + // Provide Ably with location device token + ARTPush.didRegisterForLocationNotifications(withDeviceToken: deviceToken, realtime: self.realtime) + print("Location push activated with device token: \(self.locationDeviceToken!)") + } + } +} + +// MARK: - CLLocationManagerDelegate Methods + +func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedAlways: + // Location permissions granted, activate location push monitoring + locationGrantedCallback?(true) + // Activate location push monitoring + activateLocationPush() + print("Location services always authorized.") + case .notDetermined, .authorizedWhenInUse, .restricted, .denied: + // Inform UI that location permissions were not granted + locationGrantedCallback?(false) + print("Location services unavailable for location pushes.") + break + default: + break + } +} +``` + + +Also add this in your `ARTPushRegistererDelegate` section. +It will be called after `ARTPush.didRegisterForLocationNotifications(withDeviceToken:realtime:)` completes: + + +```swift +func didUpdateAblyPush(_ error: ARTErrorInfo?) { + print("Push update: \(error?.localizedDescription ?? "Success")") + if let locationDeviceToken { + // Notify UI about activation result + activateLocationPushCallback?(locationDeviceToken, error) + } +} +``` + + +### Receiving location pushes + +You can't display notifications received by your location push extension directly in the app (since the system runs the extension independently from your app). +So you need to exchange information between the app and extension through files. + +Add the `App Groups` capability to both your app and extension. Find your app's `App ID` on the development portal and enable `Location Push Service Extension` setting in the **Additional Capabilities** tab. +Make sure both your app's identifier and location push extension's identifier use the same `App Group`. + +Replace the contents of the default `LocationPushService.swift` in your extension target with the following code: + + +```swift +import CoreLocation + +struct LocationPushEvent: Codable { + var id: UUID + var receivedAt: Date + var jsonPayload: Data +} + +class LocationPushService: NSObject, CLLocationPushServiceExtension, CLLocationManagerDelegate { + + var completion: (() -> Void)? + var locationManager: CLLocationManager! + + func didReceiveLocationPushPayload(_ payload: [String : Any], completion: @escaping () -> Void) { + recordPushPayload(payload) + + self.completion = completion + self.locationManager = CLLocationManager() + self.locationManager.delegate = self + self.locationManager.requestLocation() + } + + /** + * This method is used to exchange information between the app and the extension. + * This gives a user, who testing location pushes without access to the debug console, to see actual notifications in the `LocationPushEventsView`. + */ + private func recordPushPayload(_ payload: [String : Any]) { + guard let sharedContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier!)") else { + return print("App Groups were not configured properly. Check 'Signing & Capabilities' tab of the project settings.") + } + + let dataFileURL = sharedContainerURL.appendingPathComponent("dataFile") + + let readCoordinator = NSFileCoordinator() + var readError: NSError? = nil + var data: Data? = nil + readCoordinator.coordinate(readingItemAt: dataFileURL, error: &readError) { url in + if FileManager.default.fileExists(atPath: url.path) { + data = FileManager.default.contents(atPath: url.path)! + } + } + + guard readError == nil else { + return + } + + let event = LocationPushEvent(id: UUID(), receivedAt: Date(), jsonPayload: try! JSONSerialization.data(withJSONObject: payload)) + var events: [LocationPushEvent] = [] + + if let data { + events = try! JSONDecoder().decode([LocationPushEvent].self, from: data) + events.append(event) + } else { + events = [event] + } + + let newData = try! JSONEncoder().encode(events) + + let writeCoordinator = NSFileCoordinator() + var writeError: NSError? = nil + writeCoordinator.coordinate(writingItemAt: dataFileURL, error: &writeError) { url in + try! newData.write(to: url) + } + } + + func serviceExtensionWillTerminate() { + // Called just before the extension will be terminated by the system. + self.completion?() + } + + // MARK: - CLLocationManagerDelegate methods + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + // Process the location(s) as needed + print("Locations received: \(locations)") + self.completion?() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Location manager failed: \(error)") + self.completion?() + } +} +``` + + + + +## Step 6: Displaying the results + +Now, lets complete our `ContentView` to display all the results of everything we added above. +First, in your `PushTutorialApp.swift`, add `@UIApplicationDelegateAdaptor` wrapped `appDelegate` property +to your app `@main` struct and pass it to the `ContentView`: + + +```swift +import SwiftUI + +@main +struct PushTutorialApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + ContentView(appDelegate: appDelegate) + } + } +} +``` + + +Then, update your `ContentView.swift` to accept the `appDelegate` and display a few sections: + + +```swift +import Ably +import SwiftUI + +struct ContentView: View { + let appDelegate: AppDelegate + + @State private var statusMessage = "Ready to start" + @State private var selectedChannel = "exampleChannel1" + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Status Section + StatusSection(message: $statusMessage) + .padding() + + ScrollView { + VStack(spacing: 16) { + // Setup Section + SetupPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) + + // Send Push Section + SendPushSection(appDelegate: appDelegate, statusMessage: $statusMessage, selectedChannel: $selectedChannel) + + // Subscribe to Channel Section + ChannelSection(appDelegate: appDelegate, statusMessage: $statusMessage, selectedChannel: $selectedChannel) + + // Location Push Section + LocationPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) + } + .padding() + } + } + .navigationTitle("Push Tutorial") + } + } +} +``` + + +Each section is implemented as a separate SwiftUI `View` struct for better organization (you can add them at the bottom of the same `ContentView.swift` file): + + +```swift +// MARK: - Status Section +struct StatusSection: View { + @Binding var message: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Status") + .font(.headline) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 2) + } +} + +// MARK: - Setup Section +struct SetupPushSection: View { + let appDelegate: AppDelegate + @Binding var statusMessage: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Setup") + .font(.headline) + + VStack(spacing: 10) { + Button(action: { + appDelegate.activatePushNotifications { deviceToken, error in + if let error = error { + statusMessage = "Push activation failed: \(error.localizedDescription)" + } else { + statusMessage = "Push notifications activated with device token: \(deviceToken)" + } + } + statusMessage = "Activating push notifications..." + }) { + HStack { + Image(systemName: "checkmark.circle") + Text("Activate Push") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.green) + .foregroundStyle(.white) + .cornerRadius(8) + } + + Button(action: { + appDelegate.deactivatePush() + statusMessage = "Push notifications deactivated" + }) { + HStack { + Image(systemName: "xmark.circle") + Text("Deactivate Push") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.red) + .foregroundStyle(.white) + .cornerRadius(8) + } + + Button(action: { + appDelegate.getDeviceDetails { details, error in + if let details = details { + print("Device details: \(details)") + statusMessage = """ + Device ID: \(details.id) + Client ID: \(details.clientId ?? "n/a") + Platform: \(details.platform) + Form Factor: \(details.formFactor) + """ + } else { + statusMessage = "Failed to retrieve device details: \(error?.localizedDescription ?? "Unknown error")" + } + } + }) { + HStack { + Image(systemName: "info.circle.fill") + Text("Get Device Details") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundStyle(.white) + .cornerRadius(8) + } + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 2) + } +} + +// MARK: - Send Push Section + +func titleForChannel(_ name: String) -> String { + let titles = [ + "exampleChannel1": "Channel 1", + "exampleChannel2": "Channel 2" + ] + return titles[name] ?? name +} + +struct SendPushSection: View { + let appDelegate: AppDelegate + @Binding var statusMessage: String + @Binding var selectedChannel: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Send Push Notifications") + .font(.headline) + + VStack(spacing: 10) { + Button(action: { + appDelegate.sendPushToDevice() + statusMessage = "Sending push to device ID..." + }) { + HStack { + Image(systemName: "phone.badge.checkmark") + Text("Send Push to Device ID") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.purple) + .foregroundStyle(.white) + .cornerRadius(8) + } + + Button(action: { + appDelegate.sendPushToClientId() + statusMessage = "Sending push to client ID..." + }) { + HStack { + Image(systemName: "person.crop.circle.badge.checkmark") + Text("Send Push to Client ID") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.orange) + .foregroundStyle(.white) + .cornerRadius(8) + } + + HStack(spacing: 8) { + Menu { + Button(titleForChannel("exampleChannel1")) { + selectedChannel = "exampleChannel1" + } + Button(titleForChannel("exampleChannel2")) { + selectedChannel = "exampleChannel2" + } + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease.circle") + Text(titleForChannel(selectedChannel)) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + } + + Button(action: { + appDelegate.sendPushToChannel(selectedChannel) + statusMessage = "Sending push to channel: \(selectedChannel)..." + }) { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Send") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.cyan) + .foregroundStyle(.white) + .cornerRadius(8) + } + } + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 2) + } +} + +// MARK: - Channel Section +struct ChannelSection: View { + let appDelegate: AppDelegate + @Binding var statusMessage: String + @Binding var selectedChannel: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Channel Subscription") + .font(.headline) + + VStack(spacing: 10) { + HStack(spacing: 8) { + Menu { + Button(titleForChannel("exampleChannel1")) { + selectedChannel = "exampleChannel1" + } + Button(titleForChannel("exampleChannel2")) { + selectedChannel = "exampleChannel2" + } + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease.circle") + Text(titleForChannel(selectedChannel)) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + } + + Button(action: { + appDelegate.subscribeToChannel(selectedChannel) + statusMessage = "Subscribed to: \(titleForChannel(selectedChannel))" + }) { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Subscribe") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.indigo) + .foregroundStyle(.white) + .cornerRadius(8) + } + } + + Button(action: { + appDelegate.unsubscribeFromChannel(selectedChannel) + statusMessage = "Unsubscribed from: \(titleForChannel(selectedChannel))" + }) { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Unsubscribe from \(titleForChannel(selectedChannel))") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray) + .foregroundStyle(.white) + .cornerRadius(8) + } + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 2) + } +} + +// MARK: - Location Push Section +struct LocationPushSection: View { + let appDelegate: AppDelegate + @Binding var statusMessage: String + @State var isLocationPushEnabled = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Location Push") + .font(.headline) + + VStack(spacing: 10) { + Button(action: { + appDelegate.enableLocationPush { granted in + if granted { + isLocationPushEnabled = true + statusMessage = "Location push authorization granted." + } else { + isLocationPushEnabled = false + statusMessage = "Location push authorization denied or restricted." + } + } tokenCallback: { deviceToken, error in + if let error = error { + isLocationPushEnabled = false + statusMessage = "Location push activation failed: \(error.localizedDescription)" + } else { + statusMessage = "Location push notifications activated with device token: \(deviceToken)" + } + } + }) { + HStack { + Image(systemName: "mappin.circle.fill") + Text("Enable Location Push") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.green) + .foregroundStyle(.white) + .cornerRadius(8) + } + + Button(action: { + appDelegate.disableLocationPush() + isLocationPushEnabled = false + statusMessage = "Location push disabled" + }) { + HStack { + Image(systemName: "mappin.circle") + Text("Disable Location Push") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray) + .foregroundStyle(.white) + .cornerRadius(8) + } + + HStack { + Image(systemName: isLocationPushEnabled ? "checkmark.circle.fill" : "xmark.circle") + Text(isLocationPushEnabled ? "Location Push: Enabled" : "Location Push: Disabled") + Spacer() + } + .font(.caption) + .foregroundStyle(isLocationPushEnabled ? .green : .gray) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 2) + } +} +``` + + +You might also want to view recorded location push events in your app. +Create a new SwiftUI view `LocationPushEventsView.swift` with this content: + + +```swift +import SwiftUI +import Combine + +struct LocationPushEvent: Identifiable, Codable { + var id: UUID + var receivedAt: Date + var jsonPayload: Data +} + +class DataLoader: NSObject, NSFilePresenter, ObservableObject { + var presentedItemOperationQueue: OperationQueue = .main + var notificationObservers: [Any] = [] + + @Published private(set) var events: [LocationPushEvent] = [] + + override init() { + super.init() + loadEvents() + + let didEnterBackgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.tearDownCoordinator() + } + notificationObservers.append(didEnterBackgroundObserver) + + let willEnterForegroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.setUpCoordinator() + self?.loadEvents() + } + notificationObservers.append(willEnterForegroundObserver) + } + + func setUpCoordinator() { + print("set up coordinator") + NSFileCoordinator.addFilePresenter(self) + } + + func tearDownCoordinator() { + print("tear down coordinator") + NSFileCoordinator.removeFilePresenter(self) + } + + deinit { + print("deinit DataLoader") + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + func loadEvents() { + guard let dataFileURL = presentedItemURL else { + return print("App Groups were not configured properly. Check 'Signing & Capabilities' tab of the project settings.") + } + + let readCoordinator = NSFileCoordinator() + var readError: NSError? = nil + var data: Data? = nil + readCoordinator.coordinate(readingItemAt: dataFileURL, error: &readError) { url in + if FileManager.default.fileExists(atPath: url.path) { + data = FileManager.default.contents(atPath: url.path)! + } + } + + guard readError == nil else { + return + } + + if let data { + events = try! JSONDecoder().decode([LocationPushEvent].self, from: data) + } else { + events = [] + } + } + + var presentedItemURL: URL? { + let sharedContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier!).AblyLocationPush") + + return sharedContainerURL?.appendingPathComponent("dataFile") + } + + func presentedItemDidChange() { + loadEvents() + } +} + +struct LocationPushEventsView: View { + @StateObject private var dataLoader = DataLoader() + + var body: some View { + return List(dataLoader.events.sorted { $0.receivedAt > $1.receivedAt }) { event in + VStack(alignment: .leading) { + Text("Received at: \(event.receivedAt.ISO8601Format())") + Text("Payload: \(payloadDescription(for: event))") + } + } + .navigationTitle("Location push events") + .onAppear { dataLoader.setUpCoordinator() } + .onDisappear { dataLoader.tearDownCoordinator() } + } + + private func payloadDescription(for event: LocationPushEvent) -> String { + return String(data: event.jsonPayload, encoding: .utf8)! + } +} +``` + + +`DataLoader` loads data recorded by the location push extension and updates the view when new data is available. +Add a button to the `Location Push Section` view to navigate to this new view: + + +```swift +NavigationLink(destination: LocationPushEventsView()) { + HStack { + Image(systemName: "list.clipboard.fill") + Text("Location Push Events") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.teal) + .foregroundStyle(.white) + .cornerRadius(8) +} +``` + + +Once you run the app on a real device, you should be able to activate push notifications and see the status updates in the UI. + +Check our GitHub repository for the complete push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) + +## Next steps + +Continue to explore the documentation with Swift as the selected language: + +* Check our push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) +* Understand [token authentication](/docs/auth/token) before going to production. +* Explore [push notification administration](/docs/push/admin) for managing devices and subscriptions. +* Learn about [channel rules](/docs/push/channel-rules) for channel-based push notifications. +* Read more about the [Push Admin API](/docs/api/realtime-sdk?lang=swift#push-admin). +* Check out the [Push Notifications](/docs/push) documentation for advanced use cases. + +You can also explore the [Ably SDK for Swift](https://github.com/ably/ably-cocoa) on GitHub, or visit the [API references](/docs/api/realtime-sdk?lang=swift) for additional functionality. From dcd68ab399a85470539070527c4bb8a342344f55 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Tue, 27 Jan 2026 15:00:10 +0100 Subject: [PATCH 2/5] Removed unnecessary push location content. --- src/pages/docs/push/getting-started/apns.mdx | 233 +------------------ 1 file changed, 3 insertions(+), 230 deletions(-) diff --git a/src/pages/docs/push/getting-started/apns.mdx b/src/pages/docs/push/getting-started/apns.mdx index 0ca8364c38..c53fe12217 100644 --- a/src/pages/docs/push/getting-started/apns.mdx +++ b/src/pages/docs/push/getting-started/apns.mdx @@ -475,106 +475,9 @@ func didUpdateAblyPush(_ error: ARTErrorInfo?) { ### Receiving location pushes -You can't display notifications received by your location push extension directly in the app (since the system runs the extension independently from your app). -So you need to exchange information between the app and extension through files. - -Add the `App Groups` capability to both your app and extension. Find your app's `App ID` on the development portal and enable `Location Push Service Extension` setting in the **Additional Capabilities** tab. -Make sure both your app's identifier and location push extension's identifier use the same `App Group`. - -Replace the contents of the default `LocationPushService.swift` in your extension target with the following code: - - -```swift -import CoreLocation - -struct LocationPushEvent: Codable { - var id: UUID - var receivedAt: Date - var jsonPayload: Data -} - -class LocationPushService: NSObject, CLLocationPushServiceExtension, CLLocationManagerDelegate { - - var completion: (() -> Void)? - var locationManager: CLLocationManager! - - func didReceiveLocationPushPayload(_ payload: [String : Any], completion: @escaping () -> Void) { - recordPushPayload(payload) - - self.completion = completion - self.locationManager = CLLocationManager() - self.locationManager.delegate = self - self.locationManager.requestLocation() - } - - /** - * This method is used to exchange information between the app and the extension. - * This gives a user, who testing location pushes without access to the debug console, to see actual notifications in the `LocationPushEventsView`. - */ - private func recordPushPayload(_ payload: [String : Any]) { - guard let sharedContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier!)") else { - return print("App Groups were not configured properly. Check 'Signing & Capabilities' tab of the project settings.") - } - - let dataFileURL = sharedContainerURL.appendingPathComponent("dataFile") - - let readCoordinator = NSFileCoordinator() - var readError: NSError? = nil - var data: Data? = nil - readCoordinator.coordinate(readingItemAt: dataFileURL, error: &readError) { url in - if FileManager.default.fileExists(atPath: url.path) { - data = FileManager.default.contents(atPath: url.path)! - } - } - - guard readError == nil else { - return - } - - let event = LocationPushEvent(id: UUID(), receivedAt: Date(), jsonPayload: try! JSONSerialization.data(withJSONObject: payload)) - var events: [LocationPushEvent] = [] - - if let data { - events = try! JSONDecoder().decode([LocationPushEvent].self, from: data) - events.append(event) - } else { - events = [event] - } - - let newData = try! JSONEncoder().encode(events) - - let writeCoordinator = NSFileCoordinator() - var writeError: NSError? = nil - writeCoordinator.coordinate(writingItemAt: dataFileURL, error: &writeError) { url in - try! newData.write(to: url) - } - } - - func serviceExtensionWillTerminate() { - // Called just before the extension will be terminated by the system. - self.completion?() - } - - // MARK: - CLLocationManagerDelegate methods - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - // Process the location(s) as needed - print("Locations received: \(locations)") - self.completion?() - } - - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - print("Location manager failed: \(error)") - self.completion?() - } -} -``` - - - +Once you've added location push extension to your project, Xcode gives you a default implementation of the +`LocationPushService.swift` file in your extension target. +Use `locationManager(_:didUpdateLocations)` delegate method to handle location updates as needed. ## Step 6: Displaying the results @@ -994,136 +897,6 @@ struct LocationPushSection: View { ``` -You might also want to view recorded location push events in your app. -Create a new SwiftUI view `LocationPushEventsView.swift` with this content: - - -```swift -import SwiftUI -import Combine - -struct LocationPushEvent: Identifiable, Codable { - var id: UUID - var receivedAt: Date - var jsonPayload: Data -} - -class DataLoader: NSObject, NSFilePresenter, ObservableObject { - var presentedItemOperationQueue: OperationQueue = .main - var notificationObservers: [Any] = [] - - @Published private(set) var events: [LocationPushEvent] = [] - - override init() { - super.init() - loadEvents() - - let didEnterBackgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in - self?.tearDownCoordinator() - } - notificationObservers.append(didEnterBackgroundObserver) - - let willEnterForegroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in - self?.setUpCoordinator() - self?.loadEvents() - } - notificationObservers.append(willEnterForegroundObserver) - } - - func setUpCoordinator() { - print("set up coordinator") - NSFileCoordinator.addFilePresenter(self) - } - - func tearDownCoordinator() { - print("tear down coordinator") - NSFileCoordinator.removeFilePresenter(self) - } - - deinit { - print("deinit DataLoader") - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } - } - - func loadEvents() { - guard let dataFileURL = presentedItemURL else { - return print("App Groups were not configured properly. Check 'Signing & Capabilities' tab of the project settings.") - } - - let readCoordinator = NSFileCoordinator() - var readError: NSError? = nil - var data: Data? = nil - readCoordinator.coordinate(readingItemAt: dataFileURL, error: &readError) { url in - if FileManager.default.fileExists(atPath: url.path) { - data = FileManager.default.contents(atPath: url.path)! - } - } - - guard readError == nil else { - return - } - - if let data { - events = try! JSONDecoder().decode([LocationPushEvent].self, from: data) - } else { - events = [] - } - } - - var presentedItemURL: URL? { - let sharedContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier!).AblyLocationPush") - - return sharedContainerURL?.appendingPathComponent("dataFile") - } - - func presentedItemDidChange() { - loadEvents() - } -} - -struct LocationPushEventsView: View { - @StateObject private var dataLoader = DataLoader() - - var body: some View { - return List(dataLoader.events.sorted { $0.receivedAt > $1.receivedAt }) { event in - VStack(alignment: .leading) { - Text("Received at: \(event.receivedAt.ISO8601Format())") - Text("Payload: \(payloadDescription(for: event))") - } - } - .navigationTitle("Location push events") - .onAppear { dataLoader.setUpCoordinator() } - .onDisappear { dataLoader.tearDownCoordinator() } - } - - private func payloadDescription(for event: LocationPushEvent) -> String { - return String(data: event.jsonPayload, encoding: .utf8)! - } -} -``` - - -`DataLoader` loads data recorded by the location push extension and updates the view when new data is available. -Add a button to the `Location Push Section` view to navigate to this new view: - - -```swift -NavigationLink(destination: LocationPushEventsView()) { - HStack { - Image(systemName: "list.clipboard.fill") - Text("Location Push Events") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.teal) - .foregroundStyle(.white) - .cornerRadius(8) -} -``` - - Once you run the app on a real device, you should be able to activate push notifications and see the status updates in the UI. Check our GitHub repository for the complete push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) From bbd625ce2ed01c86618e5e054b95b6ea2cf14cc6 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Fri, 30 Jan 2026 02:11:55 +0100 Subject: [PATCH 3/5] Address PR feedback. --- src/pages/docs/push/getting-started/apns.mdx | 709 ++++++++++--------- 1 file changed, 381 insertions(+), 328 deletions(-) diff --git a/src/pages/docs/push/getting-started/apns.mdx b/src/pages/docs/push/getting-started/apns.mdx index c53fe12217..bf71d56e45 100644 --- a/src/pages/docs/push/getting-started/apns.mdx +++ b/src/pages/docs/push/getting-started/apns.mdx @@ -17,6 +17,26 @@ You'll learn how to set up your `AppDelegate` to manage push notifications, regi 5. You'll need a real iOS device to test push notifications (the simulator doesn't support APNs). 6. Set up Apple Push Notification service (APNs) certificates through the [Apple Developer Portal](https://developer.apple.com/). +### Install Ably CLI (Optional) + +Use the [Ably CLI](https://github.com/ably/cli) as an additional client to quickly test Pub/Sub features and push notifications. + +1. Install the Ably CLI: + + +```shell +npm install -g @ably/cli +``` + + +2. Run the following to log in to your Ably account and set the default app and API key: + + +```shell +ably login +``` + + ### Set up APNs certificates To enable push notifications, you need to configure APNs on Apple's developer portal: @@ -31,14 +51,7 @@ To enable push notifications, you need to configure APNs on Apple's developer po ### Create a Swift project with Xcode -Create a new iOS project with SwiftUI. For detailed instructions, refer to the [Apple Developer documentation](https://developer.apple.com/documentation/swiftui). - -1. Create a new iOS project in Xcode. -2. Select **App** as the template -3. Name the project **PushTutorial** and set the bundle identifier (such as `your.company.PushTutorial`) -4. Set the minimum iOS deployment target to iOS 15.0 or higher -5. Select SwiftUI as the interface and Swift as the language -6. Add the Ably SDK dependency to your project: +Create a new iOS SwiftUI project and add the Ably SDK dependency to it: - In Xcode, go to **File > Add Package Dependencies** - Enter the repository URL: `https://github.com/ably/ably-cocoa` @@ -46,17 +59,16 @@ Create a new iOS project with SwiftUI. For detailed instructions, refer to the [ Update your project settings: -1. Select your project in Xcode's Project Navigator. -2. Select the target for your app. -3. Go to the **Signing & Capabilities** tab. -4. Make sure you've selected your development team and that a provisioning profile has been created. -5. Add the **Push Notifications** capability by clicking **+ Capability**. +1. Select the target for your app and go to the **Signing & Capabilities** tab. +2. Make sure you've selected your development team and that a provisioning profile has been created. +3. Add the **Push Notifications** capability by clicking **+ Capability**. -All code can be added directly to your `ContentView.swift` and `AppDelegate.swift` files. +All further code can be added directly to your `ContentView.swift` and `AppDelegate.swift` files. -## Step 1: Setting up Ably +## Step 1: Set up Ably -Create `AppDelegate.swift` file and add the `AppDelegate` class which should conform to the following protocols: `UIApplicationDelegate`, `ARTPushRegistererDelegate`, `UNUserNotificationCenterDelegate`, and `CLLocationManagerDelegate`. +Create `AppDelegate.swift` file and add the `AppDelegate` class which should conform to the following protocols: +`UIApplicationDelegate`, `ARTPushRegistererDelegate`, `UNUserNotificationCenterDelegate`, and `CLLocationManagerDelegate`. Set Ably realtime client, notification center, and location manager in your `application:didFinishLaunchingWithOptions` delegate method as shown below: @@ -106,7 +118,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, ARTPushRegistererDelegate, U Here you also have some properties defined to manage device tokens and callbacks for UI which we'll use later. -## Step 2: Setting up push notifications +## Step 2: Set up push notifications To send and receive push notifications you need to provide `ably-cocoa` with the device token received from Apple in the `application:didRegisterForRemoteNotificationsWithDeviceToken` delegate method. @@ -135,6 +147,7 @@ func application(_ application: UIApplication, didFailToRegisterForRemoteNotific /// Request notification permissions from user func requestUserNotificationAuthorization() { + // Request authorization for alerts, sounds, and badges UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in DispatchQueue.main.async { if granted { @@ -148,6 +161,7 @@ func requestUserNotificationAuthorization() { /// Activate push notifications func activatePushNotifications(_ callback: @escaping (String, ARTErrorInfo?) -> ()) { + // Store callback since activation is asynchronous activatePushCallback = callback // Request notification permissions requestUserNotificationAuthorization() @@ -183,7 +197,7 @@ func didDeactivateAblyPush(_ error: ARTErrorInfo?) { ``` -## Step 3: Receiving push notifications +## Step 3: Receive push notifications Use `UNUserNotificationCenterDelegate` methods to receive push notifications. You've set the notification center delegate in the `application:didFinishLaunchingWithOptions` method. @@ -196,8 +210,8 @@ Add these methods to your `AppDelegate` class: /// Handle notification when app is in foreground func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo print("Notification received in foreground: \(userInfo)") @@ -205,10 +219,10 @@ func userNotificationCenter(_ center: UNUserNotificationCenter, completionHandler([.banner, .sound, .badge]) } -/// Handle notification when user taps on it +/// Handle notification when user taps on notification when app is in background func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) { + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo print("Notification tapped: \(userInfo)") @@ -217,8 +231,8 @@ func userNotificationCenter(_ center: UNUserNotificationCenter, ``` -To receive messages on the channel as push notifications, you need to subscribe to the push notifications -in the channel first: +Push notifications can be sent either directly to your device ID (or client ID), +or posted to a channel, in which case you first need to subscribe your device to: ```swift @@ -252,11 +266,13 @@ func unsubscribeFromChannel(_ channelName: String) { ``` -To test this code, you can send push notifications from the Ably dashboard or using the API (see the next step). +Sending push notifications using device ID or client ID requires `push-admin` capability for your API key. +Use this method for testing purposes. In a production environment, you would typically send push notifications +from your backend server (by posting messages with `push` `extras` field to a channel). -Alternatively, you can use Ably CLI to send push notifications. -First, [install](https://github.com/ably/cli) Ably CLI. -Then, use the following command to send a push notification using a client ID (or a device ID): +To test push notifications in your app, you can use [Ably dashboard](https://ably.com/dashboard) or Ably CLI. + +To send to your client ID using Ably CLI paste the following command into your terminal: ```shell @@ -267,221 +283,10 @@ ably push publish --client-id push-tutorial-client \ ``` -Or to a channel: - - -```shell -ably channels publish --api-key "{{API_KEY}}" exampleChannel1 '{"data":{"foo":"bar","baz":"qux"},"extras":{"push":{"notification":{"title":"Test push","body":"Hello from CLI!"}}}}' -``` - - -You need to login to the Ably CLI before running this command: - - -```shell -ably login -``` - - - -## Step 4: Sending push notifications - -In order to send push notifications from the app you need to add `push-admin` capability for your API Key -in the Ably dashboard. Usually, push notifications are sent from your backend server, but for demonstration -purposes you can send them directly from the app using the Ably Realtime client's Push Admin API. - -Add the following methods to your `AppDelegate` class: - - -```swift -// MARK: - Send Push Notifications - -/// Send push notification to a specific device ID -func sendPushToDevice() { - let recipient = [ - "deviceId": realtime.device.id - ] - let data = [ - "notification": [ - "title": "Push Tutorial", - "body": "Hello from device ID!" - ], - "data": [ - "foo": "bar", - "baz": "qux" - ] - ] - realtime.push.admin.publish(recipient, data: data) { error in - print("Publish result: \(error?.localizedDescription ?? "Success")") - } -} - -/// Send push notification to a specific client ID -func sendPushToClientId() { - let recipient = [ - "clientId": realtime.auth.clientId ?? "push-tutorial-client" - ] - let data = [ - "notification": [ - "title": "Push Tutorial", - "body": "Hello from client ID!" - ], - "data": [ - "foo": "bar", - "baz": "qux" - ] - ] - realtime.push.admin.publish(recipient, data: data) { error in - print("Publish result: \(error?.localizedDescription ?? "Success")") - } -} -``` - - -You can also send push notifications by publishing a message with a `push` message's `extras` field to a specific channel: - -```swift -/// Send push notification to a specific channel by publishing a message with a push extras field -func sendPushToChannel(_ channelName: String) { - let message = ARTMessage(name: "example", data: "Hello from channel!") - message.extras = [ - "push": [ - "notification": [ - "title": "Channel Push", - "body": "Sent push to \(channelName)" - ], - "data": [ - "foo": "bar", - "baz": "qux" - ] - ] - ] as any ARTJsonCompatible - - realtime.channels.get(channelName).publish([message]) { error in - if let error { - print("Error sending push to \(channelName) with error: \(error.localizedDescription)") - } else { - print("Sent push to \(channelName)") - } - } -} -``` - - -You don't need admin capabilities to send push notifications to a channel. - -## Step 5: Location pushes - -Starting from iOS 15 you can efficiently receive location requests as push notifications. -For that you need to apply for the special entitlement on the [Apple Developer Portal](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_location_push). - -Add `Location (when in use)`, `Location (Always)`, `Location Push Service Extension`, and -`Push Notifications` capabilities to the **Signing & Capabilities** tab in the Xcode project target settings. - -Add `Location Push Service Extension` target as described at the [Apple Developer Portal](https://developer.apple.com/documentation/CoreLocation/creating-a-location-push-service-extension). -For simplicity, use **Automatically manage signing**, so all needed identifiers are created for you by -Xcode (with XC prefix in their display name). Your Location Push Service Extension should have a bundle -identifier of your app with a suffix of extension's product name (e.g., `the.company.TheApp.TheExtension`). - -Add these methods to your `AppDelegate` class: - - -```swift -// MARK: - Location push methods - -/// Enable location push monitoring -func enableLocationPush(grantedCallback: @escaping (Bool) -> (), tokenCallback: @escaping (String, ARTErrorInfo?) -> ()) { - // Store callbacks since location permission request is asynchronous - locationGrantedCallback = grantedCallback - activateLocationPushCallback = tokenCallback - - switch locationManager.authorizationStatus { - case .authorizedAlways: - // Location permissions already granted - locationGrantedCallback?(true) - // Activate location push monitoring - activateLocationPush() - print("Location push enabled") - case .notDetermined: - // Request location permissions from user with 'Always' authorization needed for location pushes - locationManager.requestAlwaysAuthorization() - case .denied, .restricted, .authorizedWhenInUse: - locationGrantedCallback?(false) - print("Location permission denied or restricted") - @unknown default: - break - } -} - -/// Disable location push monitoring -func disableLocationPush() { - locationManager?.stopUpdatingLocation() - print("Location push disabled") -} - -/// Activate location push monitoring -func activateLocationPush() { - print("Starting monitoring location pushes...") - locationManager.startMonitoringLocationPushes { deviceToken, error in - guard error == nil else { - return ARTPush.didFailToRegisterForLocationNotificationsWithError(error!, realtime: self.realtime) - } - if let deviceToken { - // Convert device token data to a hex string - self.locationDeviceToken = deviceToken.map { String(format: "%02x", UInt($0)) }.joined() - // Provide Ably with location device token - ARTPush.didRegisterForLocationNotifications(withDeviceToken: deviceToken, realtime: self.realtime) - print("Location push activated with device token: \(self.locationDeviceToken!)") - } - } -} - -// MARK: - CLLocationManagerDelegate Methods - -func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - switch manager.authorizationStatus { - case .authorizedAlways: - // Location permissions granted, activate location push monitoring - locationGrantedCallback?(true) - // Activate location push monitoring - activateLocationPush() - print("Location services always authorized.") - case .notDetermined, .authorizedWhenInUse, .restricted, .denied: - // Inform UI that location permissions were not granted - locationGrantedCallback?(false) - print("Location services unavailable for location pushes.") - break - default: - break - } -} -``` - - -Also add this in your `ARTPushRegistererDelegate` section. -It will be called after `ARTPush.didRegisterForLocationNotifications(withDeviceToken:realtime:)` completes: - - -```swift -func didUpdateAblyPush(_ error: ARTErrorInfo?) { - print("Push update: \(error?.localizedDescription ?? "Success")") - if let locationDeviceToken { - // Notify UI about activation result - activateLocationPushCallback?(locationDeviceToken, error) - } -} -``` - - -### Receiving location pushes - -Once you've added location push extension to your project, Xcode gives you a default implementation of the -`LocationPushService.swift` file in your extension target. -Use `locationManager(_:didUpdateLocations)` delegate method to handle location updates as needed. +For sending pushes via channel we need some actual UI to be able to subscribe to this channel. So, let's build one. -## Step 6: Displaying the results +## Step 4: Build the UI -Now, lets complete our `ContentView` to display all the results of everything we added above. First, in your `PushTutorialApp.swift`, add `@UIApplicationDelegateAdaptor` wrapped `appDelegate` property to your app `@main` struct and pass it to the `ContentView`: @@ -518,23 +323,18 @@ struct ContentView: View { var body: some View { NavigationStack { VStack(spacing: 0) { - // Status Section + // Status Section (always visible at the top) StatusSection(message: $statusMessage) .padding() + // Scrollable sections ScrollView { VStack(spacing: 16) { // Setup Section SetupPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) - // Send Push Section - SendPushSection(appDelegate: appDelegate, statusMessage: $statusMessage, selectedChannel: $selectedChannel) - // Subscribe to Channel Section ChannelSection(appDelegate: appDelegate, statusMessage: $statusMessage, selectedChannel: $selectedChannel) - - // Location Push Section - LocationPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) } .padding() } @@ -546,7 +346,9 @@ struct ContentView: View { ``` -Each section is implemented as a separate SwiftUI `View` struct for better organization (you can add them at the bottom of the same `ContentView.swift` file): +Each section is implemented as a separate SwiftUI `View` struct for better organization. +Since this is not SwiftUI tutorial, we will not go into details of each section's implementation. +They are just a few buttons with some basic styling. You can add this code at the bottom of the same `ContentView.swift` file: ```swift @@ -655,8 +457,9 @@ struct SetupPushSection: View { } } -// MARK: - Send Push Section +// MARK: - Channel Section +// Helper to get a user-friendly title for a channel func titleForChannel(_ name: String) -> String { let titles = [ "exampleChannel1": "Channel 1", @@ -665,81 +468,66 @@ func titleForChannel(_ name: String) -> String { return titles[name] ?? name } -struct SendPushSection: View { +struct ChannelSection: View { let appDelegate: AppDelegate @Binding var statusMessage: String @Binding var selectedChannel: String var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Send Push Notifications") + Text("Channel Subscription") .font(.headline) VStack(spacing: 10) { - Button(action: { - appDelegate.sendPushToDevice() - statusMessage = "Sending push to device ID..." - }) { - HStack { - Image(systemName: "phone.badge.checkmark") - Text("Send Push to Device ID") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.purple) - .foregroundStyle(.white) - .cornerRadius(8) - } - - Button(action: { - appDelegate.sendPushToClientId() - statusMessage = "Sending push to client ID..." - }) { - HStack { - Image(systemName: "person.crop.circle.badge.checkmark") - Text("Send Push to Client ID") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.orange) - .foregroundStyle(.white) - .cornerRadius(8) - } - - HStack(spacing: 8) { - Menu { - Button(titleForChannel("exampleChannel1")) { - selectedChannel = "exampleChannel1" - } - Button(titleForChannel("exampleChannel2")) { - selectedChannel = "exampleChannel2" - } - } label: { - HStack { - Image(systemName: "line.3.horizontal.decrease.circle") - Text(titleForChannel(selectedChannel)) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.gray.opacity(0.2)) - .cornerRadius(8) + HStack(spacing: 8) { + Menu { + Button(titleForChannel("exampleChannel1")) { + selectedChannel = "exampleChannel1" + } + Button(titleForChannel("exampleChannel2")) { + selectedChannel = "exampleChannel2" + } + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease.circle") + Text(titleForChannel(selectedChannel)) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) } Button(action: { - appDelegate.sendPushToChannel(selectedChannel) - statusMessage = "Sending push to channel: \(selectedChannel)..." + appDelegate.subscribeToChannel(selectedChannel) + statusMessage = "Subscribed to: \(titleForChannel(selectedChannel))" }) { HStack { Image(systemName: "checkmark.circle.fill") - Text("Send") + Text("Subscribe") } .frame(maxWidth: .infinity) .padding() - .background(Color.cyan) + .background(Color.indigo) .foregroundStyle(.white) .cornerRadius(8) } } + + Button(action: { + appDelegate.unsubscribeFromChannel(selectedChannel) + statusMessage = "Unsubscribed from: \(titleForChannel(selectedChannel))" + }) { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Unsubscribe from \(titleForChannel(selectedChannel))") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray) + .foregroundStyle(.white) + .cornerRadius(8) + } } } .padding() @@ -748,19 +536,160 @@ struct SendPushSection: View { .shadow(radius: 2) } } +``` + -// MARK: - Channel Section -struct ChannelSection: View { +Build and run your app in Xcode on a real device. You will see the UI with sections to activate +push notifications and subscribe to channels. Tap "Activate Push" button and wait until status message +will display received device token. Try sending push using client ID or device ID as shown earlier. +You can get your device ID from the device details button (don't confuse it with the device token). + +To test pushes via channel, subscribe to "Channel 1" in the UI and post a message to the "exampleChannel1" +with `push` `extras` field using Ably CLI: + + +```shell +ably channels publish --api-key "{{API_KEY}}" exampleChannel1 '{"data":{"foo":"bar","baz":"qux"},"extras":{"push":{"notification":{"title":"Test push","body":"Hello from CLI!"}}}}' +``` + + +If you unsubscribe from this channel in the app's UI, you will no longer receive push notifications for that channel. +Try to send the same command again and verify that no notification is received. + +You can also send push notifications right from your app. The next step will show you how. + +## Step 5: Send push notifications + +The same way you can send push notifications through Ably CLI or dashboard, you can also send them directly from your app, +using both device ID and client ID or channel publishing methods. In the latter case you don't need the admin capabilities +within your API Key. + +Add the following methods to your `AppDelegate` class: + + +```swift +// MARK: - Send Push Notifications + +/// Send push notification to a specific device ID +func sendPushToDevice() { + let recipient = [ + "deviceId": realtime.device.id + ] + let data = [ + "notification": [ + "title": "Push Tutorial", + "body": "Hello from device ID!" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + realtime.push.admin.publish(recipient, data: data) { error in + print("Publish result: \(error?.localizedDescription ?? "Success")") + } +} + +/// Send push notification to a specific client ID +func sendPushToClientId() { + let recipient = [ + "clientId": realtime.auth.clientId ?? "push-tutorial-client" + ] + let data = [ + "notification": [ + "title": "Push Tutorial", + "body": "Hello from client ID!" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + realtime.push.admin.publish(recipient, data: data) { error in + print("Publish result: \(error?.localizedDescription ?? "Success")") + } +} +``` + + +Sending to a channel is just publishing a message on a channel with a `push` `extras` field: + + +```swift +/// Send push notification to a specific channel by publishing a message with a push extras field +func sendPushToChannel(_ channelName: String) { + let message = ARTMessage(name: "example", data: "Hello from channel!") + message.extras = [ + "push": [ + "notification": [ + "title": "Channel Push", + "body": "Sent push to \(channelName)" + ], + "data": [ + "foo": "bar", + "baz": "qux" + ] + ] + ] as any ARTJsonCompatible + + realtime.channels.get(channelName).publish([message]) { error in + if let error { + print("Error sending push to \(channelName) with error: \(error.localizedDescription)") + } else { + print("Sent push to \(channelName)") + } + } +} +``` + + +Now add buttons for these methods in the new `SendPushSection` view struct: + + +```swift +// MARK: - Send Push Section + +struct SendPushSection: View { let appDelegate: AppDelegate @Binding var statusMessage: String @Binding var selectedChannel: String var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Channel Subscription") + Text("Send Push Notifications") .font(.headline) VStack(spacing: 10) { + Button(action: { + appDelegate.sendPushToDevice() + statusMessage = "Sending push to device ID..." + }) { + HStack { + Image(systemName: "phone.badge.checkmark") + Text("Send Push to Device ID") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.purple) + .foregroundStyle(.white) + .cornerRadius(8) + } + + Button(action: { + appDelegate.sendPushToClientId() + statusMessage = "Sending push to client ID..." + }) { + HStack { + Image(systemName: "person.crop.circle.badge.checkmark") + Text("Send Push to Client ID") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.orange) + .foregroundStyle(.white) + .cornerRadius(8) + } + HStack(spacing: 8) { Menu { Button(titleForChannel("exampleChannel1")) { @@ -781,35 +710,20 @@ struct ChannelSection: View { } Button(action: { - appDelegate.subscribeToChannel(selectedChannel) - statusMessage = "Subscribed to: \(titleForChannel(selectedChannel))" + appDelegate.sendPushToChannel(selectedChannel) + statusMessage = "Sending push to channel: \(selectedChannel)..." }) { HStack { Image(systemName: "checkmark.circle.fill") - Text("Subscribe") + Text("Send") } .frame(maxWidth: .infinity) .padding() - .background(Color.indigo) + .background(Color.cyan) .foregroundStyle(.white) .cornerRadius(8) } } - - Button(action: { - appDelegate.unsubscribeFromChannel(selectedChannel) - statusMessage = "Unsubscribed from: \(titleForChannel(selectedChannel))" - }) { - HStack { - Image(systemName: "xmark.circle.fill") - Text("Unsubscribe from \(titleForChannel(selectedChannel))") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.gray) - .foregroundStyle(.white) - .cornerRadius(8) - } } } .padding() @@ -818,6 +732,133 @@ struct ChannelSection: View { .shadow(radius: 2) } } +``` + + +Update your `ContentView` body to include this section (add it just after the `ChannelSection`): + + +```swift +// Send Push Section +SendPushSection(appDelegate: appDelegate, statusMessage: $statusMessage, selectedChannel: $selectedChannel) +``` + + +Build and run your app again. Use the added section to send push notifications. + +## Step 6: Location pushes + +Starting from iOS 15 you can efficiently receive location requests as push notifications. +For that you need to apply for the special entitlement on the [Apple Developer Portal](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_location_push). + +Add `Location (when in use)`, `Location (Always)`, `Location Push Service Extension`, and +`Push Notifications` capabilities to the **Signing & Capabilities** tab in the Xcode project target settings. + +Add `Location Push Service Extension` target as described at the [Apple Developer Portal](https://developer.apple.com/documentation/CoreLocation/creating-a-location-push-service-extension). +For simplicity, use **Automatically manage signing**, so all needed identifiers are created for you by +Xcode (with XC prefix in their display name). Your Location Push Service Extension should have a bundle +identifier of your app with a suffix of extension's product name (e.g., `the.company.TheApp.TheExtension`). + +Add these methods to your `AppDelegate` class: + + +```swift +// MARK: - Location push methods + +/// Enable location push monitoring +func enableLocationPush(grantedCallback: @escaping (Bool) -> (), tokenCallback: @escaping (String, ARTErrorInfo?) -> ()) { + // Store callbacks since location permission request is asynchronous + locationGrantedCallback = grantedCallback + activateLocationPushCallback = tokenCallback + + switch locationManager.authorizationStatus { + case .authorizedAlways: + // Location permissions already granted + locationGrantedCallback?(true) + // Activate location push monitoring + activateLocationPush() + print("Location push enabled") + case .notDetermined: + // Request location permissions from user with 'Always' authorization needed for location pushes + locationManager.requestAlwaysAuthorization() + case .denied, .restricted, .authorizedWhenInUse: + locationGrantedCallback?(false) + print("Location permission denied or restricted") + @unknown default: + break + } +} + +/// Disable location push monitoring +func disableLocationPush() { + locationManager?.stopUpdatingLocation() + print("Location push disabled") +} + +/// Activate location push monitoring +func activateLocationPush() { + print("Starting monitoring location pushes...") + locationManager.startMonitoringLocationPushes { deviceToken, error in + guard error == nil else { + return ARTPush.didFailToRegisterForLocationNotificationsWithError(error!, realtime: self.realtime) + } + if let deviceToken { + // Convert device token data to a hex string + self.locationDeviceToken = deviceToken.map { String(format: "%02x", UInt($0)) }.joined() + // Provide Ably with location device token + ARTPush.didRegisterForLocationNotifications(withDeviceToken: deviceToken, realtime: self.realtime) + print("Location push activated with device token: \(self.locationDeviceToken!)") + } + } +} + +// MARK: - CLLocationManagerDelegate Methods + +func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedAlways: + // Location permissions granted, activate location push monitoring + locationGrantedCallback?(true) + // Activate location push monitoring + activateLocationPush() + print("Location services always authorized.") + case .notDetermined, .authorizedWhenInUse, .restricted, .denied: + // Inform UI that location permissions were not granted + locationGrantedCallback?(false) + print("Location services unavailable for location pushes.") + break + default: + break + } +} +``` + + +Also add this in your `ARTPushRegistererDelegate` section. +It will be called after `ARTPush.didRegisterForLocationNotifications(withDeviceToken:realtime:)` completes: + + +```swift +func didUpdateAblyPush(_ error: ARTErrorInfo?) { + print("Push update: \(error?.localizedDescription ?? "Success")") + if let locationDeviceToken { + // Notify UI about activation result + activateLocationPushCallback?(locationDeviceToken, error) + } +} +``` + + +### Receiving location pushes + +Once you've added location push extension to your project, Xcode gives you a default implementation of the +`LocationPushService.swift` file in your extension target. +Use `locationManager(_:didUpdateLocations)` delegate method to handle location updates as needed. + +Now add the `LocationPushSection` to your `ContentView.swift` file to enable location push from the UI: + + +```swift // MARK: - Location Push Section struct LocationPushSection: View { @@ -897,7 +938,19 @@ struct LocationPushSection: View { ``` -Once you run the app on a real device, you should be able to activate push notifications and see the status updates in the UI. +Don't forget to include this section in your `ContentView` body (add it just after the `SendPushSection`): + + +```swift +// Location Push Section +LocationPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) +``` + + +Build and run your app. Enable location push from the UI and grant location permissions when prompted. + +Use Ably dashboard or CLI to send location push notifications to your device ID or client ID. +Check that your app receives them correctly in the `LocationPushService.swift` file. Check our GitHub repository for the complete push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) @@ -905,7 +958,7 @@ Check our GitHub repository for the complete push example: [AblyPushExample](htt Continue to explore the documentation with Swift as the selected language: -* Check our push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) +* Check our [push example](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) * Understand [token authentication](/docs/auth/token) before going to production. * Explore [push notification administration](/docs/push/admin) for managing devices and subscriptions. * Learn about [channel rules](/docs/push/channel-rules) for channel-based push notifications. From b75fcc46b3db8de244d47bbe26ba16d0bbbe33c8 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Fri, 30 Jan 2026 13:15:14 +0100 Subject: [PATCH 4/5] Proof-reading by claude. --- src/pages/docs/push/getting-started/apns.mdx | 42 ++++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/pages/docs/push/getting-started/apns.mdx b/src/pages/docs/push/getting-started/apns.mdx index bf71d56e45..fc65b451e7 100644 --- a/src/pages/docs/push/getting-started/apns.mdx +++ b/src/pages/docs/push/getting-started/apns.mdx @@ -67,10 +67,10 @@ All further code can be added directly to your `ContentView.swift` and `AppDeleg ## Step 1: Set up Ably -Create `AppDelegate.swift` file and add the `AppDelegate` class which should conform to the following protocols: +Create an `AppDelegate.swift` file and add the `AppDelegate` class which should conform to the following protocols: `UIApplicationDelegate`, `ARTPushRegistererDelegate`, `UNUserNotificationCenterDelegate`, and `CLLocationManagerDelegate`. -Set Ably realtime client, notification center, and location manager in your +Set up the Ably realtime client, notification center, and location manager in your `application:didFinishLaunchingWithOptions` delegate method as shown below: @@ -116,11 +116,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, ARTPushRegistererDelegate, U ``` -Here you also have some properties defined to manage device tokens and callbacks for UI which we'll use later. +Here you also have some properties defined to manage device tokens and callbacks for the UI which we'll use later. ## Step 2: Set up push notifications -To send and receive push notifications you need to provide `ably-cocoa` with the device token received +To send and receive push notifications, you need to provide `ably-cocoa` with the device token received from Apple in the `application:didRegisterForRemoteNotificationsWithDeviceToken` delegate method. You also need to request notification permissions from the user and register your device with Ably. To handle registration results, you'll implement the `ARTPushRegistererDelegate` methods. @@ -219,7 +219,7 @@ func userNotificationCenter(_ center: UNUserNotificationCenter, completionHandler([.banner, .sound, .badge]) } -/// Handle notification when user taps on notification when app is in background +/// Handle notification when user taps on the notification when the app is in the background func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { @@ -232,7 +232,7 @@ func userNotificationCenter(_ center: UNUserNotificationCenter, Push notifications can be sent either directly to your device ID (or client ID), -or posted to a channel, in which case you first need to subscribe your device to: +or posted to a channel, in which case you first need to subscribe your device to that channel: ```swift @@ -266,7 +266,7 @@ func unsubscribeFromChannel(_ channelName: String) { ``` -Sending push notifications using device ID or client ID requires `push-admin` capability for your API key. +Sending push notifications using device ID or client ID requires the `push-admin` capability for your API key. Use this method for testing purposes. In a production environment, you would typically send push notifications from your backend server (by posting messages with `push` `extras` field to a channel). @@ -283,7 +283,7 @@ ably push publish --client-id push-tutorial-client \ ``` -For sending pushes via channel we need some actual UI to be able to subscribe to this channel. So, let's build one. +For sending pushes via a channel, we need some actual UI to be able to subscribe to this channel. So, let's build one. ## Step 4: Build the UI @@ -347,7 +347,7 @@ struct ContentView: View { Each section is implemented as a separate SwiftUI `View` struct for better organization. -Since this is not SwiftUI tutorial, we will not go into details of each section's implementation. +Since this is not a SwiftUI tutorial, we will not go into details of each section's implementation. They are just a few buttons with some basic styling. You can add this code at the bottom of the same `ContentView.swift` file: @@ -540,12 +540,12 @@ struct ChannelSection: View { Build and run your app in Xcode on a real device. You will see the UI with sections to activate -push notifications and subscribe to channels. Tap "Activate Push" button and wait until status message -will display received device token. Try sending push using client ID or device ID as shown earlier. +push notifications and subscribe to channels. Tap the "Activate Push" button and wait until the status message +displays the received device token. Try sending push using client ID or device ID as shown earlier. You can get your device ID from the device details button (don't confuse it with the device token). To test pushes via channel, subscribe to "Channel 1" in the UI and post a message to the "exampleChannel1" -with `push` `extras` field using Ably CLI: +with a `push` `extras` field using Ably CLI: ```shell @@ -554,15 +554,15 @@ ably channels publish --api-key "{{API_KEY}}" exampleChannel1 '{"data":{"foo":"b If you unsubscribe from this channel in the app's UI, you will no longer receive push notifications for that channel. -Try to send the same command again and verify that no notification is received. +Send the same command again and verify that no notification is received. You can also send push notifications right from your app. The next step will show you how. ## Step 5: Send push notifications -The same way you can send push notifications through Ably CLI or dashboard, you can also send them directly from your app, -using both device ID and client ID or channel publishing methods. In the latter case you don't need the admin capabilities -within your API Key. +Just as you can send push notifications through the Ably CLI or dashboard, you can also send them directly from your app +using device ID, client ID, or channel publishing methods. For channel publishing, you don't need the admin capabilities +for your API key. Add the following methods to your `AppDelegate` class: @@ -748,8 +748,8 @@ Build and run your app again. Use the added section to send push notifications. ## Step 6: Location pushes -Starting from iOS 15 you can efficiently receive location requests as push notifications. -For that you need to apply for the special entitlement on the [Apple Developer Portal](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_location_push). +Starting from iOS 15, you can efficiently receive location requests as push notifications. +To do this, you need to apply for the special entitlement on the [Apple Developer Portal](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_location_push). Add `Location (when in use)`, `Location (Always)`, `Location Push Service Extension`, and `Push Notifications` capabilities to the **Signing & Capabilities** tab in the Xcode project target settings. @@ -779,7 +779,7 @@ func enableLocationPush(grantedCallback: @escaping (Bool) -> (), tokenCallback: activateLocationPush() print("Location push enabled") case .notDetermined: - // Request location permissions from user with 'Always' authorization needed for location pushes + // Request location permissions from the user with 'Always' authorization needed for location pushes locationManager.requestAlwaysAuthorization() case .denied, .restricted, .authorizedWhenInUse: locationGrantedCallback?(false) @@ -851,7 +851,7 @@ func didUpdateAblyPush(_ error: ARTErrorInfo?) { ### Receiving location pushes -Once you've added location push extension to your project, Xcode gives you a default implementation of the +Once you've added the location push extension to your project, Xcode gives you a default implementation of the `LocationPushService.swift` file in your extension target. Use `locationManager(_:didUpdateLocations)` delegate method to handle location updates as needed. @@ -950,7 +950,7 @@ LocationPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) Build and run your app. Enable location push from the UI and grant location permissions when prompted. Use Ably dashboard or CLI to send location push notifications to your device ID or client ID. -Check that your app receives them correctly in the `LocationPushService.swift` file. +Verify that your app receives and handles them correctly in the `LocationPushService.swift` file. Check our GitHub repository for the complete push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush) From 1037cdb8b89a33cb0122e5d1954fca94721dec60 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Fri, 30 Jan 2026 14:46:32 +0100 Subject: [PATCH 5/5] Add UI pic, link to Apple dashboard and curl command. --- .../apns-swift-getting-started-guide.png | Bin 0 -> 69428 bytes src/pages/docs/push/getting-started/apns.mdx | 38 ++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/images/content/screenshots/getting-started/apns-swift-getting-started-guide.png diff --git a/src/images/content/screenshots/getting-started/apns-swift-getting-started-guide.png b/src/images/content/screenshots/getting-started/apns-swift-getting-started-guide.png new file mode 100644 index 0000000000000000000000000000000000000000..885a996389d0a8aa7920c30b7bdb69d77526c047 GIT binary patch literal 69428 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 II;Vst07 literal 0 HcmV?d00001 diff --git a/src/pages/docs/push/getting-started/apns.mdx b/src/pages/docs/push/getting-started/apns.mdx index fc65b451e7..5477a35973 100644 --- a/src/pages/docs/push/getting-started/apns.mdx +++ b/src/pages/docs/push/getting-started/apns.mdx @@ -54,7 +54,7 @@ To enable push notifications, you need to configure APNs on Apple's developer po Create a new iOS SwiftUI project and add the Ably SDK dependency to it: - In Xcode, go to **File > Add Package Dependencies** - - Enter the repository URL: `https://github.com/ably/ably-cocoa` + - Enter the repository URL: https://github.com/ably/ably-cocoa - Select the latest version and add it to your target Update your project settings: @@ -270,7 +270,8 @@ Sending push notifications using device ID or client ID requires the `push-admin Use this method for testing purposes. In a production environment, you would typically send push notifications from your backend server (by posting messages with `push` `extras` field to a channel). -To test push notifications in your app, you can use [Ably dashboard](https://ably.com/dashboard) or Ably CLI. +To test push notifications in your app, you can use [Ably dashboard](https://ably.com/dashboard), +[Apple developer dashboard](https://icloud.developer.apple.com/dashboard/) or Ably CLI. To send to your client ID using Ably CLI paste the following command into your terminal: @@ -542,7 +543,11 @@ struct ChannelSection: View { Build and run your app in Xcode on a real device. You will see the UI with sections to activate push notifications and subscribe to channels. Tap the "Activate Push" button and wait until the status message displays the received device token. Try sending push using client ID or device ID as shown earlier. -You can get your device ID from the device details button (don't confuse it with the device token). +You can get your device ID from the device details button (don't confuse it with the device token): + +![Screenshot of the Swift push tutorial application](../../../../images/content/screenshots/getting-started/apns-swift-getting-started-guide.png) + +### Send push via channel To test pushes via channel, subscribe to "Channel 1" in the UI and post a message to the "exampleChannel1" with a `push` `extras` field using Ably CLI: @@ -558,10 +563,10 @@ Send the same command again and verify that no notification is received. You can also send push notifications right from your app. The next step will show you how. -## Step 5: Send push notifications +## Step 5: Send pushes with code Just as you can send push notifications through the Ably CLI or dashboard, you can also send them directly from your app -using device ID, client ID, or channel publishing methods. For channel publishing, you don't need the admin capabilities +using device ID (or client ID), or channel publishing methods. For channel publishing, you don't need the admin capabilities for your API key. Add the following methods to your `AppDelegate` class: @@ -948,9 +953,28 @@ LocationPushSection(appDelegate: appDelegate, statusMessage: $statusMessage) Build and run your app. Enable location push from the UI and grant location permissions when prompted. +Use Ably dashboard, Apple dashboard, or Ably CLI to send location push notifications to your device. + +You can also use the following `cURL` command to send location pushes: + + +```shell +curl -v \ + --header "authorization: bearer ${AUTHENTICATION_TOKEN}" \ + --header "apns-topic: ${BUNDLE_ID}.location-query" \ + --header "apns-push-type: location" \ + --data '{"aps":{}}' \ + --http2 https://api.development.push.apple.com:443/3/device/${DEVICE_TOKEN} +``` + + +Replace `${BUNDLE_ID}` with your app's bundle identifier, `${AUTHENTICATION_TOKEN}` with your APNs authentication token, +and `${DEVICE_TOKEN}` with the location device token you received in the console logs after enabling location push +(don't confuse it with the device ID). + +Read how to obtain the `AUTHENTICATION_TOKEN` on [Apple Developer Portal](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns). -Use Ably dashboard or CLI to send location push notifications to your device ID or client ID. -Verify that your app receives and handles them correctly in the `LocationPushService.swift` file. +Verify that your app receives and handles location push notifications correctly in the `LocationPushService` class. Check our GitHub repository for the complete push example: [AblyPushExample](https://github.com/ably/ably-cocoa/tree/main/Examples/AblyPush)