diff --git a/Sources/AppcuesKit/Appcues.swift b/Sources/AppcuesKit/Appcues.swift index 84e215c8c..a706914ee 100644 --- a/Sources/AppcuesKit/Appcues.swift +++ b/Sources/AppcuesKit/Appcues.swift @@ -245,14 +245,8 @@ public class Appcues: NSObject { /// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` function: @objc public func setPushToken(_ deviceToken: Data?) { - storage.pushToken = deviceToken?.map { String(format: "%02x", $0) }.joined() - - if sessionID != nil { - analyticsPublisher.publish(TrackingUpdate( - type: .event(name: Events.Device.deviceUpdated.rawValue, interactive: false), - isInternal: true - )) - } + let pushMonitor = container.resolve(PushMonitoring.self) + pushMonitor.setPushToken(deviceToken) } /// Register a trait that modifies an `Experience`. @@ -318,6 +312,25 @@ public class Appcues: NSObject { _ = container.resolve(UIKitScreenTracker.self) } + /// Enables automatic push notification management. + /// + /// This should be called in `UIApplicationDelegate.application(_:didFinishLaunchingWithOptions:)` to ensure no incoming notifications are missed. + /// + /// The following will automatically be handled: + /// 1. Calling `UIApplication.registerForRemoteNotifications()` + /// 2. Implementing `UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` + /// to call ``setPushToken(_:)`` + /// 3. Ensuring `UNUserNotificationCenter.current().delegate` is set + /// 4. Implementing `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)` + /// to call ``didReceiveNotification(response:completionHandler:)`` + /// 5. Implementing `UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)` + /// to show notification while the app is in the foreground + @objc + public func enableAutomaticPushConfig() { + let pushMonitor = container.resolve(PushMonitoring.self) + pushMonitor.configureAutomatically() + } + /// Verifies if an incoming URL is intended for the Appcues SDK. /// - Parameter url: The URL being opened. /// - Returns: `true` if the URL matches the Appcues URL Scheme or `false` if the URL is not known by the Appcues SDK. diff --git a/Sources/AppcuesKit/Push/PushMonitor.swift b/Sources/AppcuesKit/Push/PushMonitor.swift index c2690c8e4..7c981b249 100644 --- a/Sources/AppcuesKit/Push/PushMonitor.swift +++ b/Sources/AppcuesKit/Push/PushMonitor.swift @@ -14,6 +14,10 @@ internal protocol PushMonitoring: AnyObject { var pushBackgroundEnabled: Bool { get } var pushPrimerEligible: Bool { get } + func configureAutomatically() + + func setPushToken(_ deviceToken: Data?) + func refreshPushStatus(publishChange: Bool, completion: ((UNAuthorizationStatus) -> Void)?) func didReceiveNotification(response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool @@ -28,6 +32,10 @@ internal class PushMonitor: PushMonitoring { private let storage: DataStoring private let analyticsPublisher: AnalyticsPublishing + // Store this value to know if we need to remove the notification center observer. + // Calling remove every time would result in inadvertently initializing the shared instance. + private var configuredAutomatically = false + private(set) var pushAuthorizationStatus: UNAuthorizationStatus = .notDetermined var pushEnabled: Bool { @@ -65,6 +73,27 @@ internal class PushMonitor: PushMonitoring { refreshPushStatus(publishChange: true) } + func configureAutomatically() { + UIApplication.swizzleDidRegisterForDeviceToken() + UIApplication.shared.registerForRemoteNotifications() + + UNUserNotificationCenter.swizzleNotificationCenterGetDelegate() + AppcuesUNUserNotificationCenterDelegate.shared.register(observer: self) + + configuredAutomatically = true + } + + func setPushToken(_ deviceToken: Data?) { + storage.pushToken = deviceToken?.map { String(format: "%02x", $0) }.joined() + + if appcues?.sessionID != nil { + analyticsPublisher.publish(TrackingUpdate( + type: .event(name: Events.Device.deviceUpdated.rawValue, interactive: false), + isInternal: true + )) + } + } + func refreshPushStatus(publishChange: Bool, completion: ((UNAuthorizationStatus) -> Void)? = nil) { // Skip call to UNUserNotificationCenter.current() in tests to avoid crashing in package tests #if DEBUG @@ -201,4 +230,10 @@ internal class PushMonitor: PushMonitoring { pushAuthorizationStatus = status } #endif + + deinit { + if configuredAutomatically { + AppcuesUNUserNotificationCenterDelegate.shared.remove(observer: self) + } + } } diff --git a/Sources/AppcuesKit/Push/UIApplication+AutoConfig.swift b/Sources/AppcuesKit/Push/UIApplication+AutoConfig.swift new file mode 100644 index 000000000..2bf2f16d7 --- /dev/null +++ b/Sources/AppcuesKit/Push/UIApplication+AutoConfig.swift @@ -0,0 +1,100 @@ +// +// UIApplication+AutoConfig.swift +// AppcuesKit +// +// Created by Matt on 2024-04-11. +// Copyright © 2024 Appcues. All rights reserved. +// + +import UIKit + +extension UIApplication { + static func swizzleDidRegisterForDeviceToken() { + guard let appDelegate = UIApplication.shared.delegate else { return } + + swizzle( + appDelegate, + targetSelector: NSSelectorFromString("application:didRegisterForRemoteNotificationsWithDeviceToken:"), + placeholderSelector: #selector(appcues__placeholderApplicationDidRegisterForRemoteNotificationsWithDeviceToken), + swizzleSelector: #selector(appcues__applicationDidRegisterForRemoteNotificationsWithDeviceToken) + ) + } + + private static func swizzle( + _ delegate: UIApplicationDelegate, + targetSelector: Selector, + placeholderSelector: Selector, + swizzleSelector: Selector + ) { + // see if the currently assigned delegate has an implementation for the target selector already. + // these are optional methods in the protocol, and if they are not there already, we'll need to add + // a placeholder implementation so that we can consistently swap it with our override, which will attempt + // to call back into it, in case there was an implementation already - if we don't do this, we'll + // get invalid selector errors in these cases. + let originalMethod = class_getInstanceMethod(type(of: delegate), targetSelector) + + if originalMethod == nil { + // this is the case where the existing delegate does not have an implementation for the target selector + + guard let placeholderMethod = class_getInstanceMethod(UIApplication.self, placeholderSelector) else { + // this really shouldn't ever be nil, as that would mean the function defined a few lines below is no + // longer there, but we must nil check this call + return + } + + // add the placeholder, so it can be swizzled uniformly + class_addMethod( + type(of: delegate), + targetSelector, + method_getImplementation(placeholderMethod), + method_getTypeEncoding(placeholderMethod) + ) + } + + // swizzle the new implementation to inject our own custom logic + + // this should never be nil, as it would mean the function defined a few lines below is no longer there, + // but we must nil check this call. + guard let swizzleMethod = class_getInstanceMethod(UIApplication.self, swizzleSelector) else { return } + + // add the swizzled version - this will only succeed once for this instance, if its already there, we've already + // swizzled, and we can exit early in the next guard + let addMethodResult = class_addMethod( + type(of: delegate), + swizzleSelector, + method_getImplementation(swizzleMethod), + method_getTypeEncoding(swizzleMethod) + ) + + guard addMethodResult, + let originalMethod = originalMethod ?? class_getInstanceMethod(type(of: delegate), targetSelector), + let swizzledMethod = class_getInstanceMethod(type(of: delegate), swizzleSelector) else { + return + } + + // finally, here is where we swizzle in our custom implementation + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + + @objc + func appcues__placeholderApplicationDidRegisterForRemoteNotificationsWithDeviceToken( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + // this gives swizzling something to replace, if the existing delegate doesn't already + // implement this function. + } + + @objc + func appcues__applicationDidRegisterForRemoteNotificationsWithDeviceToken( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + AppcuesUNUserNotificationCenterDelegate.shared.didRegister(deviceToken: deviceToken) + + // Also call the original implementation + appcues__applicationDidRegisterForRemoteNotificationsWithDeviceToken(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + + } +} diff --git a/Sources/AppcuesKit/Push/UNUserNotificationCenter+AutoConfig.swift b/Sources/AppcuesKit/Push/UNUserNotificationCenter+AutoConfig.swift new file mode 100644 index 000000000..2337ed296 --- /dev/null +++ b/Sources/AppcuesKit/Push/UNUserNotificationCenter+AutoConfig.swift @@ -0,0 +1,222 @@ +// +// UNUserNotificationCenter+AutoConfig.swift +// AppcuesKit +// +// Created by Matt on 2024-04-10. +// Copyright © 2024 Appcues. All rights reserved. +// + +import Foundation +import UserNotifications + +internal class AppcuesUNUserNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { + static var shared = AppcuesUNUserNotificationCenterDelegate() + + // This is an array to support the (rare) case of multiple SDK instances supporting push + private var pushMonitors: [WeakPushMonitoring] = [] + + func register(observer: PushMonitoring) { + pushMonitors.append(WeakPushMonitoring(observer)) + } + + func remove(observer: PushMonitoring) { + pushMonitors.removeAll { $0.value === observer } + } + + func didRegister(deviceToken: Data) { + // Pass device token to all observing PushMonitor instances + pushMonitors.forEach { weakPushMonitor in + if let pushMonitor = weakPushMonitor.value { + pushMonitor.setPushToken(deviceToken) + } + } + } + + // Shared instance is called from the swizzled method + func didReceive( + _ response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Stop at the first PushMonitor that successfully handles the notification + _ = pushMonitors.first { weakPushMonitor in + if let pushMonitor = weakPushMonitor.value { + return pushMonitor.didReceiveNotification(response: response, completionHandler: completionHandler) + } + return false + } + } + + // Shared instance is called from the swizzled method + func willPresent( + _ parsedNotification: ParsedNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Behavior for all Appcues notification + if #available(iOS 14.0, *) { + completionHandler([.banner, .list]) + } else { + completionHandler(.alert) + } + } +} + +extension AppcuesUNUserNotificationCenterDelegate { + class WeakPushMonitoring { + weak var value: PushMonitoring? + + init (_ wrapping: PushMonitoring) { self.value = wrapping } + } +} + +extension UNUserNotificationCenter { + + static func swizzleNotificationCenterGetDelegate() { + // this will swap in a new getter for UNUserNotificationCenter.delegate - giving our code a chance to hook in + let originalScrollViewDelegateSelector = #selector(getter: self.delegate) + let swizzledScrollViewDelegateSelector = #selector(appcues__getNotificationCenterDelegate) + + guard let originalScrollViewMethod = class_getInstanceMethod(self, originalScrollViewDelegateSelector), + let swizzledScrollViewMethod = class_getInstanceMethod(self, swizzledScrollViewDelegateSelector) else { + return + } + + method_exchangeImplementations(originalScrollViewMethod, swizzledScrollViewMethod) + } + + // this is our custom getter logic for the UNUserNotificationCenter.delegate + @objc + private func appcues__getNotificationCenterDelegate() -> UNUserNotificationCenterDelegate? { + let delegate: UNUserNotificationCenterDelegate + + // this call looks recursive, but it is not, it is calling the swapped implementation + // to get the actual delegate value that has been assigned, if any - can be nil + if let existingDelegate = appcues__getNotificationCenterDelegate() { + delegate = existingDelegate + } else { + // if it is nil, then we assign our own delegate implementation so there is + // something hooked in to listen to notifications + delegate = AppcuesUNUserNotificationCenterDelegate.shared + self.delegate = delegate + } + + swizzle( + delegate, + targetSelector: NSSelectorFromString("userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"), + placeholderSelector: #selector(appcues__placeholderUserNotificationCenterDidReceive), + swizzleSelector: #selector(appcues__userNotificationCenterDidReceive) + ) + + swizzle( + delegate, + targetSelector: NSSelectorFromString("userNotificationCenter:willPresentNotification:withCompletionHandler:"), + placeholderSelector: #selector(appcues__placeholderUserNotificationCenterWillPresent), + swizzleSelector: #selector(appcues__userNotificationCenterWillPresent) + ) + + return delegate + } + + private func swizzle( + _ delegate: UNUserNotificationCenterDelegate, + targetSelector: Selector, + placeholderSelector: Selector, + swizzleSelector: Selector + ) { + // see if the currently assigned delegate has an implementation for the target selector already. + // these are optional methods in the protocol, and if they are not there already, we'll need to add + // a placeholder implementation so that we can consistently swap it with our override, which will attempt + // to call back into it, in case there was an implementation already - if we don't do this, we'll + // get invalid selector errors in these cases. + let originalMethod = class_getInstanceMethod(type(of: delegate), targetSelector) + + if originalMethod == nil { + // this is the case where the existing delegate does not have an implementation for the target selector + + guard let placeholderMethod = class_getInstanceMethod(UNUserNotificationCenter.self, placeholderSelector) else { + // this really shouldn't ever be nil, as that would mean the function defined a few lines below is no + // longer there, but we must nil check this call + return + } + + // add the placeholder, so it can be swizzled uniformly + class_addMethod( + type(of: delegate), + targetSelector, + method_getImplementation(placeholderMethod), + method_getTypeEncoding(placeholderMethod) + ) + } + + // swizzle the new implementation to inject our own custom logic + + // this should never be nil, as it would mean the function defined a few lines below is no longer there, + // but we must nil check this call. + guard let swizzleMethod = class_getInstanceMethod(UNUserNotificationCenter.self, swizzleSelector) else { return } + + // add the swizzled version - this will only succeed once for this instance, if its already there, we've already + // swizzled, and we can exit early in the next guard + let addMethodResult = class_addMethod( + type(of: delegate), + swizzleSelector, + method_getImplementation(swizzleMethod), + method_getTypeEncoding(swizzleMethod) + ) + + guard addMethodResult, + let originalMethod = originalMethod ?? class_getInstanceMethod(type(of: delegate), targetSelector), + let swizzledMethod = class_getInstanceMethod(type(of: delegate), swizzleSelector) else { + return + } + + // finally, here is where we swizzle in our custom implementation + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + @objc + func appcues__placeholderUserNotificationCenterDidReceive( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // this gives swizzling something to replace, if the existing delegate doesn't already + // implement this function. + } + + @objc + func appcues__placeholderUserNotificationCenterWillPresent( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // this gives swizzling something to replace, if the existing delegate doesn't already + // implement this function. + } + + @objc + func appcues__userNotificationCenterDidReceive( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if ParsedNotification(userInfo: response.notification.request.content.userInfo) != nil { + AppcuesUNUserNotificationCenterDelegate.shared.didReceive(response, withCompletionHandler: completionHandler) + } else { + // Not an Appcues push, so pass to the original implementation + appcues__userNotificationCenterDidReceive(center, didReceive: response, withCompletionHandler: completionHandler) + } + } + + @objc + func appcues__userNotificationCenterWillPresent( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + if let parsedNotification = ParsedNotification(userInfo: notification.request.content.userInfo) { + AppcuesUNUserNotificationCenterDelegate.shared.willPresent(parsedNotification, withCompletionHandler: completionHandler) + } else { + // Not an Appcues push, so pass to the original implementation + appcues__userNotificationCenterWillPresent(center, willPresent: notification, withCompletionHandler: completionHandler) + } + } +}