Skip to content

Instantly share code, notes, and snippets.

@christianselig
Created February 28, 2022 15:44
Show Gist options
  • Save christianselig/e0ec4702bd2d8c403d129aaa8f83c17d to your computer and use it in GitHub Desktop.
Save christianselig/e0ec4702bd2d8c403d129aaa8f83c17d to your computer and use it in GitHub Desktop.
Attempt at writing an async await version of the fetching flow for the device's Apple Push Notification service (APNs) token. I'm a noob go easy on me.

APNs token fetching with Swift async await

Here's an example of the goal call site I'd like to have:

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            do {
                let appDelegate = UIApplication.shared.delegate as! AppDelegate
                let token = try await appDelegate.fetchAPNsToken()
                print("Found the token! \(token)")

                // Oo now I can do fancy async await network calls to the server to register this APNs token with!
            } catch {
                print("Error getting APNs token: \(error)")
            }
        }
    }
}

The flow for registering for APNs token is a little strange (probably because it's from iPhone OS 3.0). It uses delegates, so we have to store our continuation, but on top of this, we A) can't control the responding delegate object, it has to be UIApplication, and B) multiple requests for an APNs token can theoretically come in at the same time, and UIApplication seemingly (and only sometimes!) coalesces that into a single delegate answer, so there's not necessarily a 1:1 mapping with "calling a method" and "getting a delegate response".

Anyway so here are the two solutions I tried.

The first doesn't work, the second does. With the first I tried to go right to async await from the delegate method, but I keep getting "Mutation of captured var 'self' in concurrently-executing code" as a compiler error and I don't know how to get past that. See the ❌ below.

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }
    
    var continuations: [CheckedContinuation<String, Error>] = []
    
    func fetchAPNsToken() async throws -> String {
        return try await withCheckedThrowingContinuation { continuation in
            let options: UNAuthorizationOptions = [.badge, .alert, .sound, .providesAppNotificationSettings]

            UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] isGranted, error in
                guard isGranted else {
                    continuation.resume(throwing: error!)
                    return
                }
                
                DispatchQueue.main.async {
                    self.continuations.append(continuation) // ❌ Mutation of captured var 'self' in concurrently-executing code
                    UIApplication.shared.registerForRemoteNotifications()
                }
            }
        }
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        continuations.forEach { $0.resume(returning: token) }
        continuations.removeAll()
    }
    
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        continuations.forEach { $0.resume(throwing: error) }
        continuations.removeAll()
    }
}

So I tried to write a callback style wrapper instead, which I then converted to async await. This seems to work.

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }

    var handlers: [(Result<String, Error>) -> Void] = []

    // Callback version
    func fetchAPNsToken(completion: @escaping (Result<String, Error>) -> Void) {
        let options: UNAuthorizationOptions = [.badge, .alert, .sound, .providesAppNotificationSettings]

        UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] isGranted, error in
            guard isGranted else {
                completion(.failure(APNsTokenError.notGranted))
                return
            }

            DispatchQueue.main.async {
                self?.handlers.append(completion)
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
    }

    // Async await version that leverages the above callback version
    func fetchAPNsToken() async throws -> String {
        return try await withCheckedThrowingContinuation { continuation in
            fetchAPNsToken { result in
                switch result {
                case .success(let token):
                    continuation.resume(returning: token)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        handlers.forEach { $0(.success(token)) }
        handlers.removeAll()
    }
    
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        handlers.forEach { $0(.failure(error)) }
        handlers.removeAll()
    }
}

So how'd I do? Should I just use the callback version? Is there hope of doing something different with the delegate version directly? Did I commit some horrible threading mistake?

@divadretlaw
Copy link

Does self.continuations.append(continuation) have to be Dispatched to the Main-Thread? If you would do it in the callback then the issue wouldn't be there.

@christianselig
Copy link
Author

christianselig commented Feb 28, 2022

@divadretlaw You'd need some sort of synchronization method to prevent data races with the array (dispatching to the main queue solved that in addition to being needed for the actual register call). You could probably do something by wrapping it in an actor?

@florianbuerger
Copy link

Can't test it at the moment, this should work?

import UIKit
import UserNotifications

enum APNsTokenError: Error {
	case notGranted
}

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
	@MainActor
	private var continuation: CheckedContinuation<String, Error>?

	func fetchAPNsToken() async throws -> String {
		try await withCheckedThrowingContinuation { continuation in
			self.continuation = continuation

			let options: UNAuthorizationOptions = [.badge, .alert, .sound, .providesAppNotificationSettings]
			UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] isGranted, _ in
				guard isGranted else {
					self?.continuation?.resume(throwing: APNsTokenError.notGranted)
					self?.continuation = nil
					return
				}

				UIApplication.shared.registerForRemoteNotifications()
			}
		}
	}

	func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
		let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
		continuation?.resume(returning: token)
		continuation = nil
	}

	func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
		continuation?.resume(throwing: error)
		continuation = nil
	}
}

@divadretlaw
Copy link

Yes, you are right. It's rather easy to fall into that race condition trap 😅 But yeah a @MainActor that manages those continuations should work.

@christianselig
Copy link
Author

@florianbuerger I might be misunderstanding but wouldn't this be problematic with the non-1:1 mapping of the API calls?

For instance if you call (for sake of example I'll just write it this way, but imagine it got called in tandem somehow):

UIApplication.shared.registerForRemoteNotifications()
UIApplication.shared.registerForRemoteNotifications()
UIApplication.shared.registerForRemoteNotifications()

If it's the first app launch you only get one didRegisterForRemoteNotificationsWithDeviceToken delegate callback, not three. So you'd queue up three continuations instantaneously, but only one would be resumed?

@florianbuerger
Copy link

@christianselig Yeah you are right, if you call fetchAPNsToken() multiple times, only the last one would actually succeed. So either a collection of continuations or a Task to make sure the token is only requested once?

UIApplication.shared.registerForRemoteNotifications() shouldn't be called directly anymore. Even then, the continuation would be nil and it would silently succeed/fail? (I now realized that I shouldn't try to write code while being sick 😷, so I show myself out 😅)

@christianselig
Copy link
Author

@florianbuerger What do you mean by "UIApplication.shared.registerForRemoteNotifications() shouldn't be called directly anymore"? Was it soft deprecated somewhere?

@florianbuerger
Copy link

@florianbuerger What do you mean by "UIApplication.shared.registerForRemoteNotifications() shouldn't be called directly anymore"? Was it soft deprecated somewhere?

@christianselig Well, that was written poorly 😓 I meant, in the current example, fetchAPNsToken() needs to be called, never UIApplication.shared.registerForRemoteNotifications() in order to set up the continuation(s). registerForRemoteNotifications is not deprecated in any way (that I know of)

@christianselig
Copy link
Author

Oh I gotcha!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment