Skip to content

Commit

Permalink
✨ Add one line push config
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt committed May 6, 2024
1 parent 6ba60ed commit cde4394
Show file tree
Hide file tree
Showing 4 changed files with 378 additions and 8 deletions.
29 changes: 21 additions & 8 deletions Sources/AppcuesKit/Appcues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions Sources/AppcuesKit/Push/PushMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,4 +230,10 @@ internal class PushMonitor: PushMonitoring {
pushAuthorizationStatus = status
}
#endif

deinit {
if configuredAutomatically {
AppcuesUNUserNotificationCenterDelegate.shared.remove(observer: self)
}
}
}
100 changes: 100 additions & 0 deletions Sources/AppcuesKit/Push/UIApplication+AutoConfig.swift
Original file line number Diff line number Diff line change
@@ -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)

}
}
Loading

0 comments on commit cde4394

Please sign in to comment.