Skip to content
This repository has been archived by the owner on Sep 14, 2024. It is now read-only.

Commit

Permalink
fix: add new v1 api
Browse files Browse the repository at this point in the history
BREAKING CHANGE
  • Loading branch information
gtokman committed Apr 16, 2024
1 parent 2abb1bb commit 5b91cc0
Show file tree
Hide file tree
Showing 8 changed files with 584 additions and 216 deletions.
344 changes: 344 additions & 0 deletions PushHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
import Foundation

private let completionHandlerIdKey = "completionHandlerId"

extension UNAuthorizationStatus {
var description : String {
switch self {
case .notDetermined:
return "NotDetermined"
case .denied:
return "Denied"
case .authorized:
return "Authorized"
case .provisional:
return "Provisional"
case .ephemeral:
return "Ephemeral"
@unknown default:
return "NotDetermined"
}
}
}

enum NativeEvent {
case tokenReceived
case notificationOpened
case launchNotificationOpened
case backgroundMessageReceived
case foregroundMessageReceived

var key: String {
switch(self) {
case .tokenReceived:
return "TOKEN_RECEIVED"
case .notificationOpened:
return "NOTIFICATION_OPENED"
case .launchNotificationOpened:
return "LAUNCH_NOTIFICATION_OPENED"
case .backgroundMessageReceived:
return "BACKGROUND_MESSAGE_RECEIVED"
case .foregroundMessageReceived:
return "FOREGROUND_MESSAGE_RECEIVED"
}
}

var name: String {
switch(self) {
case .tokenReceived:
return "TokenReceived"
case .notificationOpened:
return "NotificationOpened"
case .launchNotificationOpened:
return "LaunchNotificationOpened"
case .foregroundMessageReceived:
return "ForegroundMessageReceived"
case .backgroundMessageReceived:
return "BackgroundMessageReceived"
}
}
}

struct PushEvent {
var type: NativeEvent
var payload: Any
}

class PushEventManager {
static let shared = PushEventManager()

private var eventQueue: [PushEvent] = []
private var sendEvent: ((PushEvent) -> Void)?

func setSendEvent(sendEvent: @escaping (PushEvent) -> Void) {
self.sendEvent = sendEvent
flushQueuedEvents()
}

func sendEventToJS(_ event: PushEvent) {
if let sendEvent = self.sendEvent {
sendEvent(event)
} else {
eventQueue.append(event)
}
}

private func flushQueuedEvents() {
while (!eventQueue.isEmpty) {
sendEventToJS(eventQueue.removeFirst())
}
}
}

final class PushNotificationManager {
static let shared = PushNotificationManager()

private var cachedDeviceToken: String?
private var launchNotification: [AnyHashable: Any]?
private var remoteNotificationCompletionHandlers: [String: (UIBackgroundFetchResult) -> Void] = [:]
private let sharedEventManager: PushEventManager

init() {
sharedEventManager = PushEventManager.shared
setUpObservers()
}

deinit {
removeObservers()
}

func handleLaunchOptions(launchOptions: [AnyHashable: Any]) {
// 1. The host App launch is caused by a notification
if let remoteNotification = launchOptions[UIApplication.LaunchOptionsKey.remoteNotification] as? [AnyHashable: Any],
let application = RCTSharedApplication() {
// 2. The host App is launched from terminated state to the foreground
// (including transitioning to foregound), i.e. .active .inactive.
// This happens under one of below conditions:
// a. Remote notifications are not able to launch the host App (without `content-available: 1`)
// b. Remote notifications background mode was not enabled on the host App
// c. The end user disabled background refresh of the host App
// 3. This notification must be tapped by an end user which is recorded as the launch notification
if application.applicationState != .background {
launchNotification = remoteNotification

// NOTE: the notification payload will also be passed into didReceiveRemoteNotification below after
// this delegate method, didFinishLaunchingWithOptions completes.
// As this notification will already be recorded as the launch notification, it should not be sent as
// notificationOpened event, this check is handled in didReceiveRemoteNotification.
}

// Otherwise the host App is launched in the background, this notification will be sent to react-native
// as backgroundMessageReceived event in didReceiveRemoteNotification below.
// After the host App launched in the background, didFinishLaunchingWithOptions will no longer
// be fired when an end user taps a notification.
// After the host App launched in the background, it runs developers' react-native code as well.
}
}

func requestPermissions(
_ permissions: [AnyHashable: Any],
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
if RCTRunningInAppExtension() {
reject("ERROR", "requestPermissions can not be called in App Extensions", nil)
return
}

Task {
var options: UNAuthorizationOptions = []

if permissions["alert"] as? Bool == true {
options.insert(.alert)
}

if permissions["badge"] as? Bool == true {
options.insert(.badge)
}

if permissions["sound"] as? Bool == true {
options.insert(.sound)
}

if permissions["criticalAlert"] as? Bool == true {
options.insert(.criticalAlert)
}

if permissions["provisional"] as? Bool == true {
options.insert(.provisional)
}

do {
let granted = try await AUNotificationPermissions.request(options)
resolve(granted)
} catch {
reject("ERROR", error.localizedDescription, error)
}
}
}

func getPermissionStatus(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
Task {
let status = await AUNotificationPermissions.status
resolve(status.description)
}
}

func getLaunchNotification(
_ resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
let launchNotification = self.launchNotification
self.launchNotification = nil
resolve(launchNotification == nil ? NSNull() : launchNotification)
}

func setBadgeCount(_ count: Int) {
DispatchQueue.main.async {
RCTSharedApplication()?.applicationIconBadgeNumber = count
}
}

func getBadgeCount(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
DispatchQueue.main.async {
resolve(RCTSharedApplication()?.applicationIconBadgeNumber ?? 0)
}
}

func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()

// Reduce frequency of tokenReceived event emitting to RN
if (cachedDeviceToken != token) {
cachedDeviceToken = token
sharedEventManager.sendEventToJS(
PushEvent(type: NativeEvent.tokenReceived, payload: ["token": cachedDeviceToken])
)
}
}

func didFailToRegisterForRemoteNotificationsWithError(error: Error) {
print("Register for remote notifications failed due to \(error).")
}

func didReceiveRemoteNotification(
userInfo: [AnyHashable: Any],
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
if let application = RCTSharedApplication() {
switch application.applicationState {
case .background:
let completionHandlerId = UUID().uuidString
var userInfoCopy = userInfo

remoteNotificationCompletionHandlers[completionHandlerIdKey] = completionHandler
userInfoCopy[completionHandlerIdKey] = completionHandlerId

sharedEventManager.sendEventToJS(
PushEvent(type: NativeEvent.backgroundMessageReceived, payload: userInfoCopy)
)

// Expecting remoteNotificationCompletionHandlers[completionHandlerIdKey] to be called from JS to complete
// the background notification
case .inactive:
if let launchNotification = launchNotification {
if NSDictionary(dictionary: launchNotification).isEqual(to: userInfo) {
// When the last tapped notification is the same as the launch notification,
// it's sent as launchNotificationOpened event, and retrievable via getLaunchNotification.
PushEventManager.shared.sendEventToJS(
PushEvent(type: NativeEvent.launchNotificationOpened, payload: launchNotification)
)
} else {
// When a launch notification is recorded in handleLaunchOptions above,
// but the last tapped notification is not the recorded launch notification, the last
// tapped notification will be sent to react-native as notificationOpened event.
// This may happen when an end user rapidly tapped on multiple notifications.
self.launchNotification = nil
sharedEventManager.sendEventToJS(
PushEvent(type: NativeEvent.notificationOpened, payload: userInfo)
)
}
} else {
// When there is no launch notification recorded, the last tapped notification
// will be sent to react-native as notificationOpened event.
sharedEventManager.sendEventToJS(
PushEvent(type: NativeEvent.notificationOpened, payload: userInfo)
)
}
completionHandler(.noData)
case .active:
sharedEventManager.sendEventToJS(
PushEvent(type: NativeEvent.foregroundMessageReceived, payload: userInfo)
)
completionHandler(.noData)
@unknown default: break // we don't handle any possible new state added in the future for now
}
}
}

func completeNotification(_ completionHandlerId: String) {
if let completionHandler = remoteNotificationCompletionHandlers[completionHandlerId] {
completionHandler(.noData)
remoteNotificationCompletionHandlers.removeValue(forKey: completionHandlerId)
}
}

private func setUpObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}

private func removeObservers() {
NotificationCenter.default.removeObserver(
self,
name: UIApplication.didBecomeActiveNotification,
object: nil
)

NotificationCenter.default.removeObserver(
self,
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}

@objc
private func applicationDidBecomeActive() {
registerForRemoteNotifications()
}

@objc
private func applicationDidEnterBackground() {
// When App enters background we remove the cached launchNotification
// as when the App reopens after this point, there won't be a notification
// that launched the App.
launchNotification = nil
}

private func registerForRemoteNotifications() {
if RCTRunningInAppExtension() {
return
}

DispatchQueue.main.async {
RCTSharedApplication()?.registerForRemoteNotifications()
}
}
}
20 changes: 20 additions & 0 deletions PushNotification.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// PushNotification.h
// candlefinance-push
//
// Created by Gary Tokman on 4/16/24.
//

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface PushNotification : NSObject

+ (void) didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken;
+ (void) didFailToRegisterForRemoteNotificationsWithError:(NSError*)error;
+ (void) didReceiveRemoteNotification:(NSDictionary *)userInfo withCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler;

@end

NS_ASSUME_NONNULL_END
25 changes: 25 additions & 0 deletions PushNotification.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// PushNotification.m
// candlefinance-push
//
// Created by Gary Tokman on 4/16/24.
//

#import "PushNotification.h"
#import "candlefinance_push-Swift.h"

@implementation PushNotification

+ (void) didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken {
[PushNotificationAppDelegateHelper didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

+ (void) didFailToRegisterForRemoteNotificationsWithError:(NSError*)error {
[PushNotificationAppDelegateHelper didFailToRegisterForRemoteNotificationsWithError:error];
}

+ (void) didReceiveRemoteNotification:(NSDictionary*)userInfo withCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
[PushNotificationAppDelegateHelper didReceiveRemoteNotificationWithUserInfo:userInfo completionHandler:completionHandler];
}

@end
Loading

0 comments on commit 5b91cc0

Please sign in to comment.