From a729e1ad7436c2fa07bb7c59f71964b8eebde912 Mon Sep 17 00:00:00 2001 From: Douglas Lowder Date: Fri, 13 Dec 2024 02:29:26 -0800 Subject: [PATCH] [expo-notifications][iOS] Swift conversion 3: Scheduling, NotificationBuilder, NotificationCenterDelegate, Presentation (#33253) # Why Migrate scheduler, notification builder, and presentation module to Swift # How - Rewrite scheduler module in Swift - Rewrite presentation module in Swift - Create singleton to receive notification center events and allow modules to register as delegates for those events - Replace Objective C dictionary categories with a Swift implementation using Records # Test Plan - CI should pass, including SwiftLint checks Behavior of notification test app on real device should not change # Checklist - [x] Documentation is up to date to reflect these changes (eg: https://docs.expo.dev and README.md). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). --- packages/expo-notifications/CHANGELOG.md | 1 + .../expo-module.config.json | 2 +- .../Building/EXNotificationBuilder.h | 19 -- .../Building/EXNotificationBuilder.m | 105 ------ .../Building/NotificationBuilder.swift | 147 +++++++++ .../EXNotificationCenterDelegate.h | 5 +- .../EXNotificationCenterDelegate.m | 47 +-- ...Dictionary+EXNotificationsVerifyingClass.h | 7 - ...Dictionary+EXNotificationsVerifyingClass.m | 19 -- .../NotificationCenterManager.swift | 134 ++++++++ .../EXNotificationPresentationModule.h | 13 - .../EXNotificationPresentationModule.m | 124 ------- .../Presenting/PresentationModule.swift | 81 +++++ .../EXNotificationSchedulerModule.h | 21 -- .../EXNotificationSchedulerModule.m | 279 ---------------- .../Scheduling/SchedulerModule.swift | 302 ++++++++++++++++++ .../PushTokenAppDelegateSubscriber.swift | 14 +- .../PushToken/PushTokenModule.swift | 39 +-- 18 files changed, 699 insertions(+), 660 deletions(-) delete mode 100644 packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.h delete mode 100644 packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.m create mode 100644 packages/expo-notifications/ios/EXNotifications/Building/NotificationBuilder.swift delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.h delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.m create mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/NotificationCenterManager.swift delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.h delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.m create mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/PresentationModule.swift delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.h delete mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.m create mode 100644 packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/SchedulerModule.swift diff --git a/packages/expo-notifications/CHANGELOG.md b/packages/expo-notifications/CHANGELOG.md index 034a9dd65a2c4b..79115a1c4a5ee9 100644 --- a/packages/expo-notifications/CHANGELOG.md +++ b/packages/expo-notifications/CHANGELOG.md @@ -14,6 +14,7 @@ - [iOS] Swift conversion 1: badge and server registration. ([#32069](https://github.com/expo/expo/pull/32069) by [@douglowder](https://github.com/douglowder)) - [iOS] Swift conversion 2: push token module. ([#32612](https://github.com/expo/expo/pull/32612) by [@douglowder](https://github.com/douglowder)) +- [iOS] Swift conversion 3: scheduling, notification builder. ([#33253](https://github.com/expo/expo/pull/33253) by [@douglowder](https://github.com/douglowder)) ## 0.29.11 - 2024-12-05 diff --git a/packages/expo-notifications/expo-module.config.json b/packages/expo-notifications/expo-module.config.json index de403d4abe0276..0661a0177a8db7 100644 --- a/packages/expo-notifications/expo-module.config.json +++ b/packages/expo-notifications/expo-module.config.json @@ -2,7 +2,7 @@ "name": "expo-notifications", "platforms": ["ios", "android"], "ios": { - "modules": ["BadgeModule", "ServerRegistrationModule", "PushTokenModule"], + "modules": ["BadgeModule", "ServerRegistrationModule", "PushTokenModule", "SchedulerModule", "PresentationModule"], "appDelegateSubscribers": ["PushTokenAppDelegateSubscriber"] }, "android": { diff --git a/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.h b/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.h deleted file mode 100644 index 1089770d1c19be..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.h +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import - -#import - -NS_ASSUME_NONNULL_BEGIN - -@protocol EXNotificationBuilder - -- (UNMutableNotificationContent *)notificationContentFromRequest:(NSDictionary *)request; - -@end - -@interface EXNotificationBuilder : NSObject - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.m b/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.m deleted file mode 100644 index 53ec405f842d29..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Building/EXNotificationBuilder.m +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import -#import -#import - -@implementation EXNotificationBuilder - -EX_REGISTER_MODULE(); - -+ (const NSArray *)exportedInterfaces -{ - return @[@protocol(EXNotificationBuilder)]; -} - -- (UNMutableNotificationContent *)notificationContentFromRequest:(NSDictionary *)request -{ - UNMutableNotificationContent *content = [UNMutableNotificationContent new]; - [content setTitle:[request objectForKey:@"title" verifyingClass:[NSString class]]]; - [content setSubtitle:[request objectForKey:@"subtitle" verifyingClass:[NSString class]]]; - [content setBody:[request objectForKey:@"body" verifyingClass:[NSString class]]]; - [content setLaunchImageName:[request objectForKey:@"launchImageName" verifyingClass:[NSString class]]]; - [content setBadge:[request objectForKey:@"badge" verifyingClass:[NSNumber class]]]; - [content setUserInfo:[request objectForKey:@"data" verifyingClass:[NSDictionary class]]]; - [content setCategoryIdentifier:[request objectForKey:@"categoryIdentifier" verifyingClass:[NSString class]]]; - if ([request[@"sound"] isKindOfClass:[NSNumber class]]) { - [content setSound:[request[@"sound"] boolValue] ? [UNNotificationSound defaultSound] : nil]; - } else if ([request[@"sound"] isKindOfClass:[NSString class]]) { - NSString *soundName = request[@"sound"]; - if ([@"default" isEqualToString:soundName]) { - [content setSound:[UNNotificationSound defaultSound]]; - } else if ([@"defaultCritical" isEqualToString:soundName]) { - [content setSound:[UNNotificationSound defaultCriticalSound]]; - } else { - [content setSound:[UNNotificationSound soundNamed:soundName]]; - } - } - NSMutableArray *attachments = [NSMutableArray new]; - [[request objectForKey:@"attachments" verifyingClass:[NSArray class]] enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - UNNotificationAttachment *attachment = [self attachmentFromRequest:obj]; - if (attachment) { - [attachments addObject:attachment]; - } - }]; - [content setAttachments:attachments]; - NSString *interruptionLevel = [request objectForKey:@"interruptionLevel" verifyingClass:[NSString class]]; - if (interruptionLevel) { - content.interruptionLevel = [EXNotificationBuilder deserializeInterruptionLevel:interruptionLevel]; - } - return content; -} - -+ (UNNotificationInterruptionLevel)deserializeInterruptionLevel:(NSString *)interruptionLevel API_AVAILABLE(ios(15.0)) { - static NSDictionary *interruptionLevelMap; - if (!interruptionLevelMap) { - interruptionLevelMap = @{ - @"passive": @(UNNotificationInterruptionLevelPassive), - @"active": @(UNNotificationInterruptionLevelActive), - @"timeSensitive": @(UNNotificationInterruptionLevelTimeSensitive), - @"critical": @(UNNotificationInterruptionLevelCritical) - }; - } - - return [interruptionLevelMap[interruptionLevel] integerValue]; -} - -- (UNNotificationAttachment *)attachmentFromRequest:(NSDictionary *)request -{ - NSString *identifier = [request objectForKey:@"identifier" verifyingClass:[NSString class]] ?: @""; - NSURL *uri = [NSURL URLWithString:[request objectForKey:@"uri" verifyingClass:[NSString class]]]; - NSError *error = nil; - UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:identifier URL:uri options:[self attachmentOptionsFromRequest:request] error:&error]; - if (error) { - EXLogWarn(@"[expo-notifications] Could not have created a notification attachment out of request: %@. Error: %@.", [request description], [error description]); - return nil; - } - return attachment; -} - -- (NSDictionary *)attachmentOptionsFromRequest:(NSDictionary *)request -{ - NSMutableDictionary *options = [NSMutableDictionary new]; - if ([request objectForKey:@"typeHint" verifyingClass:[NSString class]]) { - options[UNNotificationAttachmentOptionsTypeHintKey] = request[@"typeHint"]; - } - if ([request objectForKey:@"hideThumbnail" verifyingClass:[NSNumber class]]) { - options[UNNotificationAttachmentOptionsThumbnailHiddenKey] = request[@"hideThumbnail"]; - } - if ([request objectForKey:@"thumbnailClipArea" verifyingClass:[NSDictionary class]]) { - NSDictionary *area = request[@"thumbnailClipArea"]; - NSNumber *x = [area objectForKey:@"x" verifyingClass:[NSNumber class]]; - NSNumber *y = [area objectForKey:@"y" verifyingClass:[NSNumber class]]; - NSNumber *width = [area objectForKey:@"width" verifyingClass:[NSNumber class]]; - NSNumber *height = [area objectForKey:@"height" verifyingClass:[NSNumber class]]; - CGRect areaRect = CGRectMake([x doubleValue], [y doubleValue], [width doubleValue], [height doubleValue]); - options[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = (__bridge id _Nullable)(CGRectCreateDictionaryRepresentation(areaRect)); - } - if ([request objectForKey:@"thumbnailTime" verifyingClass:[NSNumber class]]) { - options[UNNotificationAttachmentOptionsThumbnailTimeKey] = request[@"thumbnailTime"]; - } - return options; -} - -@end - diff --git a/packages/expo-notifications/ios/EXNotifications/Building/NotificationBuilder.swift b/packages/expo-notifications/ios/EXNotifications/Building/NotificationBuilder.swift new file mode 100644 index 00000000000000..6916264599cefb --- /dev/null +++ b/packages/expo-notifications/ios/EXNotifications/Building/NotificationBuilder.swift @@ -0,0 +1,147 @@ +// Copyright © 2024 650 Industries. All rights reserved. + +import ExpoModulesCore + +struct NotificationRequestRecord: Record { + @Field + var title: String? + @Field + var subtitle: String? + @Field + var body: String? + @Field + var launchImageName: String? + @Field + var badge: Int? + @Field + var userInfo: [String: Any]? + @Field + var categoryIdentifier: String? + @Field + var sound: Either? + @Field + var attachments: [[String: Any]]? + @Field + var interruptionLevel: String? +} + +public class NotificationBuilder: NSObject { + public class func content(_ request: [String: Any], appContext: AppContext) throws -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + let request = try NotificationRequestRecord(from: request, appContext: appContext) + + if let title = request.title { + content.title = title + } + + if let subtitle = request.subtitle { + content.subtitle = subtitle + } + + if let body = request.body { + content.body = body + } + + if let launchImageName = request.launchImageName { + content.launchImageName = launchImageName + } + + if let badge = request.badge { + // swiftlint:disable:next legacy_objc_type + content.badge = NSNumber.init(value: badge) + } + + if let userInfo = request.userInfo { + content.userInfo = userInfo + } + + if let categoryIdentifier = request.categoryIdentifier { + content.categoryIdentifier = categoryIdentifier + } + + if let sound = request.sound { + if let soundBool = try? sound.as(Bool.self) { + content.sound = soundBool ? .default : .none + } else if let soundName = try? sound.as(String.self) { + if soundName == "default" { + content.sound = UNNotificationSound.default + } else if soundName == "defaultCritical" { + content.sound = UNNotificationSound.defaultCritical + } else { + content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName)) + } + } + } + + var attachments: [UNNotificationAttachment] = [] + if let attachmentsArray = request.attachments { + for attachmentObject in attachmentsArray { + if let attachment: UNNotificationAttachment = attachment(attachmentObject) { + attachments.append(attachment) + } + } + } + content.attachments = attachments + if let interruptionLevel = request.interruptionLevel { + content.interruptionLevel = deserializeInterruptionLevel(interruptionLevel) + } + + return content + } + + class func attachment(_ request: [String: Any]) -> UNNotificationAttachment? { + let identifier = request["identifier"] as? String ?? "" + let uri = request["uri"] as? String ?? "" + do { + if let url = URL(string: uri), + let attachment: UNNotificationAttachment = + try? UNNotificationAttachment( + identifier: identifier, + url: url, + options: attachmentOptions(request) + ) { + return attachment + } + return nil + } + } + + class func attachmentOptions(_ request: [String: Any]) -> [String: Any] { + var options: [String: Any] = [:] + if let typeHint = request["typeHint"] as? String { + options[UNNotificationAttachmentOptionsTypeHintKey] = typeHint + } + if let hideThumbnail = request["hideThumbnail"] as? Bool { + options[UNNotificationAttachmentOptionsThumbnailHiddenKey] = hideThumbnail + } + if let thumbnailClipArea = request["thumbnailClipArea"] as? [String: Any] { + let x = thumbnailClipArea["x"] as? Double + let y = thumbnailClipArea["y"] as? Double + let width = thumbnailClipArea["width"] as? Double + let height = thumbnailClipArea["height"] as? Double + if let x, let y, let width, let height { + options[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = + CGRect( + x: x, + y: y, + width: width, + height: height + ) + } + } + if let thumbnailTime = request["thumbnailTime"] as? TimeInterval { + options[UNNotificationAttachmentOptionsThumbnailTimeKey] = thumbnailTime + } + return options + } + + class func deserializeInterruptionLevel(_ interruptionLevel: String) -> UNNotificationInterruptionLevel { + switch interruptionLevel { + case "passive": return .passive + case "active": return .active + case "timeSensitive": return .timeSensitive + case "critical": return .critical + default: return .passive + } + } +} diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.h b/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.h index 91436d83e5e045..d3423bff09d19c 100644 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.h +++ b/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.h @@ -22,10 +22,11 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(nullable NSDictionary *)launchOptions; - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler; -- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler; - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler; +/* +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler; - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(nullable UNNotification *)notification; - + */ @property (nonatomic, strong, nullable) UNNotificationResponse *lastNotificationResponse; @end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.m b/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.m index 934470e0d8928f..240b229eee4ec6 100644 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.m +++ b/packages/expo-notifications/ios/EXNotifications/Notifications/EXNotificationCenterDelegate.m @@ -4,10 +4,17 @@ #import #import +#if __has_include() +#import +#else +#import "EXNotifications-Swift.h" +#endif + @interface EXNotificationCenterDelegate () @property (nonatomic, strong) NSPointerArray *delegates; @property (nonatomic, strong) NSMutableArray *pendingNotificationResponses; +@property (nonatomic, weak) EXNotificationCenterManager *notificationCenterManager; @end @@ -20,6 +27,7 @@ - (instancetype)init if (self = [super init]) { _delegates = [NSPointerArray weakObjectsPointerArray]; _pendingNotificationResponses = [NSMutableArray array]; + _notificationCenterManager = [EXNotificationCenterManager shared]; } return self; } @@ -90,35 +98,8 @@ __block void (^completionHandlerCaller)(void) = ^{ - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { - __block int delegatesCalled = 0; - __block int delegatesCompleted = 0; - __block BOOL delegatingCompleted = NO; - __block UNNotificationPresentationOptions optionsSum = UNNotificationPresentationOptionNone; - __block void (^completionHandlerCaller)(void) = ^{ - if (delegatingCompleted && delegatesCompleted == delegatesCalled) { - completionHandler(optionsSum); - } - }; - - for (int i = 0; i < _delegates.count; i++) { - id pointer = [_delegates pointerAtIndex:i]; - if ([pointer respondsToSelector:@selector(userNotificationCenter:willPresentNotification:withCompletionHandler:)]) { - [pointer userNotificationCenter:center willPresentNotification:notification withCompletionHandler:^(UNNotificationPresentationOptions options) { - @synchronized (self) { - delegatesCompleted += 1; - optionsSum = optionsSum | options; - completionHandlerCaller(); - } - }]; - @synchronized (self) { - delegatesCalled += 1; - } - } - } - @synchronized (self) { - delegatingCompleted = YES; - completionHandlerCaller(); - } + // Delegate to the new Swift code + [_notificationCenterManager userNotificationCenter:center willPresentNotification:notification withCompletionHandler:completionHandler]; } - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler @@ -169,12 +150,8 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNoti - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification { - for (int i = 0; i < _delegates.count; i++) { - id pointer = [_delegates pointerAtIndex:i]; - if ([pointer respondsToSelector:@selector(userNotificationCenter:openSettingsForNotification:)]) { - [pointer userNotificationCenter:center openSettingsForNotification:notification]; - } - } + // Delegate to the new Swift manager + [_notificationCenterManager userNotificationCenter:center openSettingsForNotification:notification]; } # pragma mark - EXNotificationCenterDelegate diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.h b/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.h deleted file mode 100644 index 802a40e3f8cdd2..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.h +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface NSDictionary (EXNotificationsVerifyingClass) - -- (id)objectForKey:(id)aKey verifyingClass:(__unsafe_unretained Class)klass; - -@end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.m b/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.m deleted file mode 100644 index 8671d67898a543..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/NSDictionary+EXNotificationsVerifyingClass.m +++ /dev/null @@ -1,19 +0,0 @@ -#import - -static NSString * const invalidValueExceptionName = @"Value of invalid class encountered"; -static NSString * const invalidValueClassReasonFormat = @"Value under key `%@` is of class %@, while %@ was expected."; - -@implementation NSDictionary (EXNotificationsVerifyingClass) - -- (id)objectForKey:(id)aKey verifyingClass:(__unsafe_unretained Class)klass -{ - id obj = [self objectForKey:aKey]; - if (!obj || [obj isKindOfClass:klass]) { - return obj; - } - - NSString *reason = [NSString stringWithFormat:invalidValueClassReasonFormat, aKey, NSStringFromClass([obj class]), NSStringFromClass(klass)]; - @throw [NSException exceptionWithName:invalidValueExceptionName reason:reason userInfo:nil]; -} - -@end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/NotificationCenterManager.swift b/packages/expo-notifications/ios/EXNotifications/Notifications/NotificationCenterManager.swift new file mode 100644 index 00000000000000..ce499b01de1e95 --- /dev/null +++ b/packages/expo-notifications/ios/EXNotifications/Notifications/NotificationCenterManager.swift @@ -0,0 +1,134 @@ +import ExpoModulesCore +import Foundation + +/** + Protocol that NotificationCenterManager delegates may implement + */ +public protocol NotificationDelegate: AnyObject { + func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool + func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool + func openSettings(_ notification: UNNotification?) + func didRegister(_ deviceToken: String) + func didFailRegistration(_ error: Error) +} + +public extension NotificationDelegate { + func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { + return false + } + func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { + return false + } + func openSettings(_ notification: UNNotification?) {} + func didRegister(_ deviceToken: String) {} + func didFailRegistration(_ error: Error) {} +} + +/** + Singleton that sets itself as the UserNotificationCenter delegate, + and calls its own delegates in response to notification center calls. + */ +@objc(EXNotificationCenterManager) +public class NotificationCenterManager: NSObject, + UNUserNotificationCenterDelegate, + NotificationDelegate { + @objc + public static let shared = NotificationCenterManager() + + var delegates: [NotificationDelegate] = [] + var pendingResponses: [UNNotificationResponse] = [] + let userNotificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current() + + // TODO: Once Swift conversion is complete, the old EXNotificationDelegate class will be removed, and + // we will need to add the initialization code below. + // For now, we allow EXNotificationDelegate to add itself as the user notification delegate, and call the + // shared instance of this class. + // + /* + private override init() { + super.init() + if UNUserNotificationCenter.current().delegate != nil { + NSLog( + "[expo-notifications] EXNotificationCenterDelegate encountered already present delegate of " + + "UNUserNotificationCenter. EXNotificationCenterDelegate will not overwrite the value not to break other " + + "features of your app. In return, expo-notifications may not work properly. To fix this problem either " + + "remove setting of the second delegate, or set the delegate to an instance of EXNotificationCenterDelegate " + + "manually afterwards." + ) + return + } + UNUserNotificationCenter.current().delegate = self + } + */ + + public func addDelegate(_ delegate: NotificationDelegate) { + delegates.append(delegate) + var handled = false + for pendingResponse in pendingResponses { + handled = delegate.didReceive(pendingResponse, completionHandler: {}) + } + if handled { + pendingResponses.removeAll() + } + } + + public func removeDelegate(_ delegate: AnyObject) { + if let index = delegates.firstIndex(where: { $0 === delegate }) { + delegates.remove(at: index) + } + } + + // MARK: - Called by PushTokenAppDelegateSubscriber + + public func didFailRegistration(_ error: any Error) { + for delegate in delegates { + delegate.didFailRegistration(error) + } + } + + public func didRegister(_ deviceToken: String) { + for delegate in delegates { + delegate.didRegister(deviceToken) + } + } + + // MARK: - UNUserNotificationCenterDelegate + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var handled = false + for delegate in delegates { + handled = handled || delegate.willPresent(notification, completionHandler: completionHandler) + } + if !handled { + // TODO: For now, until all code is converted to Swift, + // ensure notification is presented even if handlers are not registered + // Later revisit this + completionHandler([.badge, .banner, .sound]) + } + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + var handled = false + for delegate in delegates { + handled = handled || delegate.didReceive(response, completionHandler: completionHandler) + } + if !handled { + pendingResponses.append(response) + } + completionHandler() + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { + for delegate in delegates { + delegate.openSettings(notification) + } + } +} diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.h b/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.h deleted file mode 100644 index f0f3dbd4d74275..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.h +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import -#import -#import - -@interface EXNotificationPresentationModule : EXExportedModule - -- (NSArray * _Nonnull)serializeNotifications:(NSArray * _Nonnull)notifications; - -- (void)dismissNotificationWithIdentifier:(NSString *)identifier resolve:(EXPromiseResolveBlock)resolve reject:(EXPromiseRejectBlock)reject; - -@end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.m b/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.m deleted file mode 100644 index 8410fa41fee0c9..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/EXNotificationPresentationModule.m +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import - -#import -#import -#import - -@interface EXNotificationPresentationModule () - -@property (nonatomic, weak) id notificationBuilder; - -// Remove once presentNotificationAsync is removed -@property (nonatomic, strong) NSCountedSet *presentedNotifications; -@property (nonatomic, weak) id notificationCenterDelegate; - -@end - -@implementation EXNotificationPresentationModule - -EX_EXPORT_MODULE(ExpoNotificationPresenter); - -// Remove once presentNotificationAsync is removed -- (instancetype)init -{ - if (self = [super init]) { - _presentedNotifications = [NSCountedSet set]; - } - return self; -} - -# pragma mark - Exported methods - -// Remove once presentNotificationAsync is removed -EX_EXPORT_METHOD_AS(presentNotificationAsync, - presentNotificationWithIdentifier:(NSString *)identifier - notification:(NSDictionary *)notificationSpec - resolve:(EXPromiseResolveBlock)resolve - reject:(EXPromiseRejectBlock)reject) -{ - UNNotificationContent *content = [_notificationBuilder notificationContentFromRequest:notificationSpec]; - UNNotificationTrigger *trigger = nil; - UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:trigger]; - [_presentedNotifications addObject:identifier]; - __weak EXNotificationPresentationModule *weakSelf = self; - [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { - if (error) { - // If there was no error, willPresentNotification: callback will remove the identifier from the set - [weakSelf.presentedNotifications removeObject:identifier]; - NSString *message = [NSString stringWithFormat:@"Notification could not have been presented: %@", error.description]; - reject(@"ERR_NOTIF_PRESENT", message, error); - } else { - resolve(identifier); - } - }]; -} - -EX_EXPORT_METHOD_AS(getPresentedNotificationsAsync, - getPresentedNotificationsAsyncWithResolve:(EXPromiseResolveBlock)resolve - reject:(EXPromiseRejectBlock)reject) -{ - [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray * _Nonnull notifications) { - resolve([self serializeNotifications:notifications]); - }]; -} - - -EX_EXPORT_METHOD_AS(dismissNotificationAsync, - dismissNotificationWithIdentifier:(NSString *)identifier - resolve:(EXPromiseResolveBlock)resolve - reject:(EXPromiseRejectBlock)reject) -{ - [[UNUserNotificationCenter currentNotificationCenter] removeDeliveredNotificationsWithIdentifiers:@[identifier]]; - resolve(nil); -} - -EX_EXPORT_METHOD_AS(dismissAllNotificationsAsync, - dismissAllNotificationsWithResolver:(EXPromiseResolveBlock)resolve - reject:(EXPromiseRejectBlock)reject) -{ - [[UNUserNotificationCenter currentNotificationCenter] removeAllDeliveredNotifications]; - resolve(nil); -} - -# pragma mark - EXModuleRegistryConsumer - -- (void)setModuleRegistry:(EXModuleRegistry *)moduleRegistry -{ - _notificationBuilder = [moduleRegistry getModuleImplementingProtocol:@protocol(EXNotificationBuilder)]; - - // Remove once presentNotificationAsync is removed - id notificationCenterDelegate = (id)[moduleRegistry getSingletonModuleForName:@"NotificationCenterDelegate"]; - [notificationCenterDelegate addDelegate:self]; -} - -// Remove once presentNotificationAsync is removed -# pragma mark - EXNotificationsDelegate - -- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler -{ - UNNotificationPresentationOptions presentationOptions = UNNotificationPresentationOptionNone; - - NSString *identifier = notification.request.identifier; - if ([_presentedNotifications containsObject:identifier]) { - [_presentedNotifications removeObject:identifier]; - // TODO(iOS 14): use UNNotificationPresentationOptionList and UNNotificationPresentationOptionBanner - presentationOptions = UNNotificationPresentationOptionSound | UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge; - } - - completionHandler(presentationOptions); -} - -# pragma mark - Helpers - -- (NSArray * _Nonnull)serializeNotifications:(NSArray * _Nonnull)notifications -{ - NSMutableArray *serializedNotifications = [NSMutableArray new]; - for (UNNotification *notification in notifications) { - [serializedNotifications addObject:[EXNotificationSerializer serializedNotification:notification]]; - } - return serializedNotifications; -} - -@end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/PresentationModule.swift b/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/PresentationModule.swift new file mode 100644 index 00000000000000..51c6d4f44e90ea --- /dev/null +++ b/packages/expo-notifications/ios/EXNotifications/Notifications/Presenting/PresentationModule.swift @@ -0,0 +1,81 @@ +// Copyright © 2024 650 Industries. All rights reserved. + +import ExpoModulesCore +import UIKit +import MachO + +public class PresentationModule: Module, NotificationDelegate { + var presentedNotifications: Set = [] + + public func definition() -> ModuleDefinition { + Name("ExpoNotificationPresenter") + + OnCreate { + NotificationCenterManager.shared.addDelegate(self) + } + + OnDestroy { + NotificationCenterManager.shared.removeDelegate(self) + } + + AsyncFunction("presentNotificationAsync") { (identifier: String, notificationSpec: [String: Any], promise: Promise) in + do { + guard let appContext = appContext else { + let error = NSError(domain: "ExpoNotificationPresenter", code: 0, userInfo: nil) + promise.reject("ERR_NOTIF_PRESENT", error.localizedDescription) + return + } + let content = try NotificationBuilder.content(notificationSpec, appContext: appContext) + var request: UNNotificationRequest? + try EXUtilities.catchException { + request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + } + guard let request = request else { + promise.reject("ERR_NOTIF_PRESENT", "Notification could not be presented") + return + } + presentedNotifications.insert(identifier) + UNUserNotificationCenter.current().add(request) { error in + if let error { + promise.reject("ERR_NOTIF_PRESENT", error.localizedDescription) + } else { + promise.resolve() + } + } + } catch { + promise.reject("ERR_NOTIF_PRESENT", error.localizedDescription) + } + } + .runOnQueue(.main) + + AsyncFunction("getPresentedNotificationsAsync") { (promise: Promise) in + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + promise.resolve(self.serializeNotifications(notifications)) + } + } + + AsyncFunction("dismissNotificationAsync") { (identifier: String) in + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + } + + AsyncFunction("dismissAllNotificationsAsync") { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + } + + public func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { + let identifier = notification.request.identifier + if presentedNotifications.contains(identifier) { + presentedNotifications.remove(identifier) + completionHandler([.badge, .sound, .banner]) // .alert is deprecated + return true + } + return false + } + + func serializeNotifications(_ notifications: [UNNotification]) -> [[AnyHashable: Any]] { + return notifications.map { notification in + return EXNotificationSerializer.serializedNotification(notification) + } + } +} diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.h b/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.h deleted file mode 100644 index a7fff4ce9628af..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.h +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface EXNotificationSchedulerModule : EXExportedModule - -- (NSArray * _Nonnull)serializeNotificationRequests:(NSArray * _Nonnull) requests; - -- (void)cancelNotification:(NSString *)identifier resolve:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject; - -- (void)cancelAllNotificationsWithResolver:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject; - -- (UNNotificationRequest *)buildNotificationRequestWithIdentifier:(NSString *)identifier content:(NSDictionary *)contentInput trigger:(NSDictionary *)triggerInput; - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.m b/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.m deleted file mode 100644 index 49e74532f297ba..00000000000000 --- a/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/EXNotificationSchedulerModule.m +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2018-present 650 Industries. All rights reserved. - -#import -#import -#import -#import - -#import - -static NSString * const notificationTriggerTypeKey = @"type"; -static NSString * const notificationTriggerRepeatsKey = @"repeats"; - -static NSString * const intervalNotificationTriggerType = @"timeInterval"; -static NSString * const intervalNotificationTriggerIntervalKey = @"seconds"; - -static NSString * const dailyNotificationTriggerType = @"daily"; -static NSString * const dailyNotificationTriggerHourKey = @"hour"; -static NSString * const dailyNotificationTriggerMinuteKey = @"minute"; - -static NSString * const weeklyNotificationTriggerType = @"weekly"; -static NSString * const weeklyNotificationTriggerWeekdayKey = @"weekday"; -static NSString * const weeklyNotificationTriggerHourKey = @"hour"; -static NSString * const weeklyNotificationTriggerMinuteKey = @"minute"; - -static NSString * const monthlyNotificationTriggerType = @"monthly"; -static NSString * const monthlyNotificationTriggerDayKey = @"day"; -static NSString * const monthlyNotificationTriggerHourKey = @"hour"; -static NSString * const monthlyNotificationTriggerMinuteKey = @"minute"; - -static NSString * const yearlyNotificationTriggerType = @"yearly"; -static NSString * const yearlyNotificationTriggerDayKey = @"day"; -static NSString * const yearlyNotificationTriggerMonthKey = @"month"; -static NSString * const yearlyNotificationTriggerHourKey = @"hour"; -static NSString * const yearlyNotificationTriggerMinuteKey = @"minute"; - -static NSString * const dateNotificationTriggerType = @"date"; -static NSString * const dateNotificationTriggerTimestampKey = @"timestamp"; - -static NSString * const calendarNotificationTriggerType = @"calendar"; -static NSString * const calendarNotificationTriggerComponentsKey = @"value"; -static NSString * const calendarNotificationTriggerTimezoneKey = @"timezone"; - - - -@interface EXNotificationSchedulerModule () - -@property (nonatomic, weak) id builder; - -@end - -@implementation EXNotificationSchedulerModule - -EX_EXPORT_MODULE(ExpoNotificationScheduler); - -- (void)setModuleRegistry:(EXModuleRegistry *)moduleRegistry -{ - _builder = [moduleRegistry getModuleImplementingProtocol:@protocol(EXNotificationBuilder)]; -} - -# pragma mark - Exported methods - -EX_EXPORT_METHOD_AS(getAllScheduledNotificationsAsync, - getAllScheduledNotifications:(EXPromiseResolveBlock)resolve reject:(EXPromiseRejectBlock)reject - ) -{ - [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray * _Nonnull requests) { - resolve([self serializeNotificationRequests:requests]); - }]; -} - -EX_EXPORT_METHOD_AS(scheduleNotificationAsync, - scheduleNotification:(NSString *)identifier notificationSpec:(NSDictionary *)notificationSpec triggerSpec:(NSDictionary *)triggerSpec resolve:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject) -{ - @try { - UNNotificationRequest *request = [self buildNotificationRequestWithIdentifier:identifier content:notificationSpec trigger:triggerSpec]; - [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { - if (error) { - NSString *message = [NSString stringWithFormat:@"Failed to schedule notification. %@", error]; - reject(@"ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", message, error); - } else { - resolve(identifier); - } - }]; - } @catch (NSException *exception) { - NSString *message = [NSString stringWithFormat:@"Failed to schedule notification. %@", exception]; - reject(@"ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", message, nil); - } -} - -EX_EXPORT_METHOD_AS(cancelScheduledNotificationAsync, - cancelNotification:(NSString *)identifier resolve:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject) -{ - [[UNUserNotificationCenter currentNotificationCenter] removePendingNotificationRequestsWithIdentifiers:@[identifier]]; - resolve(nil); -} - -EX_EXPORT_METHOD_AS(cancelAllScheduledNotificationsAsync, - cancelAllNotificationsWithResolver:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject) -{ - [[UNUserNotificationCenter currentNotificationCenter] removeAllPendingNotificationRequests]; - resolve(nil); -} - -EX_EXPORT_METHOD_AS(getNextTriggerDateAsync, - getNextTriggerDate:(NSDictionary *)triggerSpec resolve:(EXPromiseResolveBlock)resolve rejecting:(EXPromiseRejectBlock)reject) -{ - @try { - UNNotificationTrigger *trigger = [self triggerFromParams:triggerSpec]; - if ([trigger isKindOfClass:[UNCalendarNotificationTrigger class]]) { - UNCalendarNotificationTrigger *calendarTrigger = (UNCalendarNotificationTrigger *)trigger; - NSDate *nextTriggerDate = [calendarTrigger nextTriggerDate]; - // We want to return milliseconds from this method. - resolve(nextTriggerDate ? @([nextTriggerDate timeIntervalSince1970] * 1000) : [NSNull null]); - } else if ([trigger isKindOfClass:[UNTimeIntervalNotificationTrigger class]]) { - UNTimeIntervalNotificationTrigger *timeIntervalTrigger = (UNTimeIntervalNotificationTrigger *)trigger; - NSDate *nextTriggerDate = [timeIntervalTrigger nextTriggerDate]; - // We want to return milliseconds from this method. - resolve(nextTriggerDate ? @([nextTriggerDate timeIntervalSince1970] * 1000) : [NSNull null]); - } else { - NSString *message = [NSString stringWithFormat:@"It is not possible to get next trigger date for triggers other than calendar-based. Provided trigger resulted in %@ trigger.", NSStringFromClass([trigger class])]; - reject(@"ERR_NOTIFICATIONS_INVALID_CALENDAR_TRIGGER", message, nil); - } - } @catch (NSException *exception) { - NSString *message = [NSString stringWithFormat:@"Failed to get next trigger date. %@", exception]; - reject(@"ERR_NOTIFICATIONS_FAILED_TO_GET_NEXT_TRIGGER_DATE", message, nil); - } -} - -- (UNNotificationRequest *)buildNotificationRequestWithIdentifier:(NSString *)identifier - content:(NSDictionary *)contentInput - trigger:(NSDictionary *)triggerInput -{ - UNNotificationContent *content = [_builder notificationContentFromRequest:contentInput]; - UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:[self triggerFromParams:triggerInput]]; - return request; -} - -- (NSArray * _Nonnull)serializeNotificationRequests:(NSArray * _Nonnull) requests -{ - NSMutableArray *serializedRequests = [NSMutableArray new]; - for (UNNotificationRequest *request in requests) { - [serializedRequests addObject:[EXNotificationSerializer serializedNotificationRequest:request]]; - } - return serializedRequests; -} - -- (UNNotificationTrigger *)triggerFromParams:(NSDictionary *)params -{ - if (!params) { - // nil trigger is a valid trigger - return nil; - } - if (![params isKindOfClass:[NSDictionary class]]) { - NSString *reason = [NSString stringWithFormat:@"Unknown notification trigger declaration passed in, expected a dictionary, received %@.", NSStringFromClass(params.class)]; - @throw [NSException exceptionWithName:NSInvalidArgumentException reason:reason userInfo:nil]; - } - NSString *triggerType = params[notificationTriggerTypeKey]; - if ([intervalNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *interval = [params objectForKey:intervalNotificationTriggerIntervalKey verifyingClass:[NSNumber class]]; - NSNumber *repeats = [params objectForKey:notificationTriggerRepeatsKey verifyingClass:[NSNumber class]]; - - return [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:[interval unsignedIntegerValue] - repeats:[repeats boolValue]]; - } else if ([dateNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *timestampMs = [params objectForKey:dateNotificationTriggerTimestampKey verifyingClass:[NSNumber class]]; - NSUInteger timestamp = [timestampMs unsignedIntegerValue] / 1000; - NSDate *date = [NSDate dateWithTimeIntervalSince1970:timestamp]; - - return [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:[date timeIntervalSinceNow] - repeats:NO]; - - } else if ([dailyNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *hour = [params objectForKey:dailyNotificationTriggerHourKey verifyingClass:[NSNumber class]]; - NSNumber *minute = [params objectForKey:dailyNotificationTriggerMinuteKey verifyingClass:[NSNumber class]]; - NSDateComponents *dateComponents = [NSDateComponents new]; - dateComponents.hour = [hour integerValue]; - dateComponents.minute = [minute integerValue]; - - return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dateComponents - repeats:YES]; - } else if ([weeklyNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *weekday = [params objectForKey:weeklyNotificationTriggerWeekdayKey verifyingClass:[NSNumber class]]; - NSNumber *hour = [params objectForKey:weeklyNotificationTriggerHourKey verifyingClass:[NSNumber class]]; - NSNumber *minute = [params objectForKey:weeklyNotificationTriggerMinuteKey verifyingClass:[NSNumber class]]; - NSDateComponents *dateComponents = [NSDateComponents new]; - dateComponents.weekday = [weekday integerValue]; - dateComponents.hour = [hour integerValue]; - dateComponents.minute = [minute integerValue]; - - return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dateComponents - repeats:YES]; - } else if ([monthlyNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *day = [params objectForKey:monthlyNotificationTriggerDayKey verifyingClass:[NSNumber class]]; - NSNumber *hour = [params objectForKey:monthlyNotificationTriggerHourKey verifyingClass:[NSNumber class]]; - NSNumber *minute = [params objectForKey:monthlyNotificationTriggerMinuteKey verifyingClass:[NSNumber class]]; - NSDateComponents *dateComponents = [NSDateComponents new]; - dateComponents.day = [day integerValue]; - dateComponents.hour = [hour integerValue]; - dateComponents.minute = [minute integerValue]; - - return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dateComponents - repeats:YES]; - } else if ([yearlyNotificationTriggerType isEqualToString:triggerType]) { - NSNumber *day = [params objectForKey:yearlyNotificationTriggerDayKey verifyingClass:[NSNumber class]]; - NSNumber *month = [params objectForKey:yearlyNotificationTriggerMonthKey verifyingClass:[NSNumber class]]; - NSNumber *hour = [params objectForKey:yearlyNotificationTriggerHourKey verifyingClass:[NSNumber class]]; - NSNumber *minute = [params objectForKey:yearlyNotificationTriggerMinuteKey verifyingClass:[NSNumber class]]; - NSDateComponents *dateComponents = [NSDateComponents new]; - dateComponents.day = [day integerValue]; - dateComponents.month = [month integerValue] + 1; // iOS uses 1-12 based numbers for months - dateComponents.hour = [hour integerValue]; - dateComponents.minute = [minute integerValue]; - - return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dateComponents - repeats:YES]; - } else if ([calendarNotificationTriggerType isEqualToString:triggerType]) { - NSDateComponents *dateComponents = [self dateComponentsFromParams:params[calendarNotificationTriggerComponentsKey]]; - NSNumber *repeats = [params objectForKey:notificationTriggerRepeatsKey verifyingClass:[NSNumber class]]; - - return [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dateComponents - repeats:[repeats boolValue]]; - } else { - NSString *reason = [NSString stringWithFormat:@"Unknown notification trigger type: %@.", triggerType]; - @throw [NSException exceptionWithName:NSInvalidArgumentException reason:reason userInfo:nil]; - } -} - -- (NSDateComponents *)dateComponentsFromParams:(NSDictionary *)params -{ - NSDateComponents *dateComponents = [NSDateComponents new]; - - // TODO: Verify that DoW matches JS getDay() - dateComponents.calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierISO8601]; - - if ([params objectForKey:calendarNotificationTriggerTimezoneKey verifyingClass:[NSString class]]) { - dateComponents.timeZone = [[NSTimeZone alloc] initWithName:params[calendarNotificationTriggerTimezoneKey]]; - } - - for (NSString *key in [self automatchedDateComponentsKeys]) { - if (params[key]) { - NSNumber *value = [params objectForKey:key verifyingClass:[NSNumber class]]; - [dateComponents setValue:[value unsignedIntegerValue] forComponent:[self calendarUnitFor:key]]; - } - } - - return dateComponents; -} - -- (NSDictionary *)dateComponentsMatchMap -{ - static NSDictionary *map; - if (!map) { - map = @{ - @"year": @(NSCalendarUnitYear), - @"month": @(NSCalendarUnitMonth), - @"day": @(NSCalendarUnitDay), - @"hour": @(NSCalendarUnitHour), - @"minute": @(NSCalendarUnitMinute), - @"second": @(NSCalendarUnitSecond), - @"weekday": @(NSCalendarUnitWeekday), - @"weekOfMonth": @(NSCalendarUnitWeekOfMonth), - @"weekOfYear": @(NSCalendarUnitWeekOfYear), - @"weekdayOrdinal": @(NSCalendarUnitWeekdayOrdinal) - }; - } - return map; -} - -- (NSArray *)automatchedDateComponentsKeys -{ - return [[self dateComponentsMatchMap] allKeys]; -} - -- (NSCalendarUnit)calendarUnitFor:(NSString *)key -{ - return [[self dateComponentsMatchMap][key] unsignedIntegerValue]; -} - -@end diff --git a/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/SchedulerModule.swift b/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/SchedulerModule.swift new file mode 100644 index 00000000000000..f482a557a04440 --- /dev/null +++ b/packages/expo-notifications/ios/EXNotifications/Notifications/Scheduling/SchedulerModule.swift @@ -0,0 +1,302 @@ +// Copyright © 2024 650 Industries. All rights reserved. + +import ExpoModulesCore +import UIKit +import MachO + +// swiftlint:disable identifier_name +let notificationTriggerTypeKey = "type" +let notificationTriggerRepeatsKey = "repeats" + +let timeIntervalNotificationTriggerType = "timeInterval" +let dailyNotificationTriggerType = "daily" +let weeklyNotificationTriggerType = "weekly" +let monthlyNotificationTriggerType = "monthly" +let yearlyNotificationTriggerType = "yearly" +let dateNotificationTriggerType = "date" +let calendarNotificationTriggerType = "calendar" + +let calendarNotificationTriggerComponentsKey = "value" +let calendarNotificationTriggerTimezoneKey = "timezone" +// swiftlint:enable identifier_name + +let dateComponentsMatchMap: [String: Calendar.Component] = [ + "year": .year, + "month": .month, + "day": .day, + "hour": .hour, + "minute": .minute, + "second": .second, + "weekday": .weekday, + "weekOfMonth": .weekOfMonth, + "weekOfYear": .weekOfYear, + "weekdayOrdinal": .weekdayOrdinal +] + +struct CalendarTriggerRecord: Record { + @Field + var year: Int? + @Field + var month: Int? + @Field + var day: Int? + @Field + var hour: Int? + @Field + var minute: Int? + @Field + var second: Int? + @Field + var weekday: Int? + @Field + var weekOfMonth: Int? + @Field + var weekOfYear: Int? + @Field + var weekdayOrdinal: Int? + @Field + var timezone: String? + @Field + var repeats: Bool? +} + +struct TimeIntervalTriggerRecord: Record { + @Field + var seconds: TimeInterval + @Field + var repeats: Bool +} + +struct DateTriggerRecord: Record { + @Field + var timestamp: TimeInterval +} + +struct DailyTriggerRecord: Record { + @Field + var hour: Int + @Field + var minute: Int +} + +struct WeeklyTriggerRecord: Record { + @Field + var weekday: Int + @Field + var hour: Int + @Field + var minute: Int +} + +struct MonthlyTriggerRecord: Record { + @Field + var day: Int + @Field + var hour: Int + @Field + var minute: Int +} + +struct YearlyTriggerRecord: Record { + @Field + var month: Int + @Field + var day: Int + @Field + var hour: Int + @Field + var minute: Int +} + +public class SchedulerModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoNotificationScheduler") + + AsyncFunction("getAllScheduledNotificationsAsync") { (promise: Promise) in + UNUserNotificationCenter.current().getPendingNotificationRequests { (requests: [UNNotificationRequest]) in + var serializedRequests: [Any] = [] + requests.forEach {request in + serializedRequests.append(EXNotificationSerializer.serializedNotificationRequest(request)) + } + promise.resolve(serializedRequests) + } + } + .runOnQueue(.main) + + AsyncFunction("cancelScheduledNotificationAsync") { (identifier: String) in + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) + } + + AsyncFunction("cancelAllScheduledNotificationsAsync") { () in + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + + AsyncFunction("scheduleNotificationAsync") { (identifier: String, notificationSpec: [String: Any], triggerSpec: [String: Any]?, promise: Promise) in + do { + guard let request = try buildNotificationRequest(identifier: identifier, contentInput: notificationSpec, triggerInput: triggerSpec) else { + promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to build notification request") + return + } + UNUserNotificationCenter.current().add(request) {error in + if let error = error { + promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule notification, \(error)") + } else { + promise.resolve() + } + UNUserNotificationCenter.current().add(request) {error in + if let error = error { + promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule notification, \(error)") + } else { + promise.resolve(identifier) + } + } + } + } catch { + promise.reject("ERR_NOTIFICATIONS_FAILED_TO_SCHEDULE", "Failed to schedule notification, \(error)") + } + } + + AsyncFunction("getNextTriggerDateAsync") { (triggerSpec: [String: Any], promise: Promise) in + guard let appContext = appContext, + let trigger = try? triggerFromParams(triggerSpec, appContext: appContext) else { + promise.reject("ERR_NOTIFICATIONS_INVALID_CALENDAR_TRIGGER", "Invalid trigger specification") + return + } + if trigger is UNCalendarNotificationTrigger { + if let calendarTrigger = trigger as? UNCalendarNotificationTrigger, + let nextTriggerDate = calendarTrigger.nextTriggerDate() { + promise.resolve(nextTriggerDate.timeIntervalSince1970 * 1000) + } else { + promise.resolve(nil) + } + return + } + if trigger is UNTimeIntervalNotificationTrigger { + if let timeIntervalTrigger = trigger as? UNTimeIntervalNotificationTrigger, + let nextTriggerDate = timeIntervalTrigger.nextTriggerDate() { + promise.resolve(nextTriggerDate.timeIntervalSince1970 * 1000) + } else { + promise.resolve(nil) + } + return + } + promise.reject("ERR_NOTIFICATIONS_INVALID_CALENDAR_TRIGGER", "It is not possible to get next trigger date for triggers other than calendar-based. Provided trigger resulted in \(type(of: trigger)) trigger.") + } + } + + func triggerFromParams(_ params: [String: Any]?, appContext: AppContext) throws -> UNNotificationTrigger? { + guard let params = params else { + return nil + } + + guard let triggerType = params[notificationTriggerTypeKey] as? String else { + return nil + } + + switch triggerType { + case timeIntervalNotificationTriggerType: + let timeIntervalTrigger = try TimeIntervalTriggerRecord(from: params, appContext: appContext) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeIntervalTrigger.seconds, repeats: timeIntervalTrigger.repeats) + } + return trigger + case dateNotificationTriggerType: + let dateTrigger = try DateTriggerRecord(from: params, appContext: appContext) + let timestamp: Int = Int(dateTrigger.timestamp / 1000) + let date: Date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNTimeIntervalNotificationTrigger(timeInterval: date.timeIntervalSinceNow, repeats: false) + } + return trigger + case dailyNotificationTriggerType: + let dailyTrigger = try DailyTriggerRecord(from: params, appContext: appContext) + let dateComponents: DateComponents = DateComponents(hour: dailyTrigger.hour, minute: dailyTrigger.minute) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + } + return trigger + case weeklyNotificationTriggerType: + let weeklyTrigger = try WeeklyTriggerRecord(from: params, appContext: appContext) + let dateComponents: DateComponents = DateComponents(hour: weeklyTrigger.hour, minute: weeklyTrigger.minute, weekday: weeklyTrigger.weekday) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + } + return trigger + case monthlyNotificationTriggerType: + let monthlyTrigger = try MonthlyTriggerRecord(from: params, appContext: appContext) + let dateComponents: DateComponents = DateComponents(day: monthlyTrigger.day, hour: monthlyTrigger.hour, minute: monthlyTrigger.minute) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + } + return trigger + case yearlyNotificationTriggerType: + let yearlyTrigger = try YearlyTriggerRecord(from: params, appContext: appContext) + let dateComponents: DateComponents = DateComponents( + month: yearlyTrigger.month, + day: yearlyTrigger.day, + hour: yearlyTrigger.hour, + minute: yearlyTrigger.minute + ) + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + } + return trigger + case calendarNotificationTriggerType: + let calendarTrigger = try CalendarTriggerRecord(from: params, appContext: appContext) + let dateComponents: DateComponents = dateComponentsFrom(calendarTrigger) ?? DateComponents() + let repeats = calendarTrigger.repeats ?? false + var trigger: UNNotificationTrigger? + try EXUtilities.catchException { + trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: repeats) + } + return trigger + default: + return nil + } + } + + func dateComponentsFrom(_ calendarTrigger: CalendarTriggerRecord) -> DateComponents? { + var dateComponents = DateComponents() + // TODO: Verify that DoW matches JS getDay() + dateComponents.calendar = Calendar.init(identifier: .iso8601) + if let timeZone = calendarTrigger.timezone { + dateComponents.timeZone = TimeZone(identifier: timeZone) + } + dateComponentsMatchMap.keys.forEach { key in + let calendarComponent = dateComponentsMatchMap[key] ?? .day + if let value = calendarTrigger.toDictionary()[key] as? Int { + dateComponents.setValue(value, for: calendarComponent) + } + } + return dateComponents + } + + func serializeNotificationRequests(_ requests: [UNNotificationRequest]) -> [Any] { + var serializedRequests: [[AnyHashable: Any]] = [] + requests.forEach {request in + serializedRequests.append(EXNotificationSerializer .serializedNotificationRequest(request)) + } + return serializedRequests + } + + func buildNotificationRequest( + identifier: String, + contentInput: [String: Any], + triggerInput: [String: Any]? + ) throws -> UNNotificationRequest? { + guard let appContext = appContext else { + return nil + } + return try UNNotificationRequest( + identifier: identifier, + content: NotificationBuilder.content(contentInput, appContext: appContext), + trigger: triggerFromParams(triggerInput, appContext: appContext) + ) + } +} diff --git a/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenAppDelegateSubscriber.swift b/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenAppDelegateSubscriber.swift index b16f71e813ab52..1eba578039f4df 100644 --- a/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenAppDelegateSubscriber.swift +++ b/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenAppDelegateSubscriber.swift @@ -2,22 +2,14 @@ import ExpoModulesCore import Foundation public class PushTokenAppDelegateSubscriber: ExpoAppDelegateSubscriber { - public static let ExpoNotificationsRegistrationResult = Notification.Name("ExpoNotificationsRegistrationResult") + let notificationCenterManager = NotificationCenterManager.shared public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - NotificationCenter.default.post( - name: PushTokenAppDelegateSubscriber.ExpoNotificationsRegistrationResult, - object: nil, - userInfo: ["deviceToken": dataToString(deviceToken)] - ) + notificationCenterManager.didRegister(dataToString(deviceToken)) } public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { - NotificationCenter.default.post( - name: PushTokenAppDelegateSubscriber.ExpoNotificationsRegistrationResult, - object: nil, - userInfo: ["error": error] - ) + notificationCenterManager.didFailRegistration(error) } } diff --git a/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenModule.swift b/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenModule.swift index 7b6c5b77500b59..22353c8b33b3f5 100644 --- a/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenModule.swift +++ b/packages/expo-notifications/ios/EXNotifications/PushToken/PushTokenModule.swift @@ -6,46 +6,26 @@ import MachO let onDevicePushTokenEventName = "onDevicePushToken" -public class PushTokenModule: Module { +public class PushTokenModule: Module, NotificationDelegate { var promiseNotYetResolved: Promise? - @objc - public func onExpoNotificationsRegistrationResult(notification: Notification) { - guard let userInfo = notification.userInfo else { - return - } - if let error = userInfo["error"] as? (any Error) { - promiseNotYetResolved?.reject(error) - promiseNotYetResolved = nil - } else if let deviceToken = userInfo["deviceToken"] as? String { - promiseNotYetResolved?.resolve(deviceToken) - promiseNotYetResolved = nil - self.sendEvent(onDevicePushTokenEventName, ["devicePushToken": deviceToken]) - } - } - public func definition() -> ModuleDefinition { Name("ExpoPushTokenManager") Events([onDevicePushTokenEventName]) OnStartObserving(onDevicePushTokenEventName) { - NotificationCenter.default.addObserver( - self, - selector: #selector(onExpoNotificationsRegistrationResult), - name: PushTokenAppDelegateSubscriber.ExpoNotificationsRegistrationResult, - object: nil - ) + NotificationCenterManager.shared.addDelegate(self) } OnStopObserving(onDevicePushTokenEventName) { - // swiftlint:disable:next notification_center_detachment - NotificationCenter.default.removeObserver(self) + NotificationCenterManager.shared.removeDelegate(self) } AsyncFunction("getDevicePushTokenAsync") { (promise: Promise) in if promiseNotYetResolved != nil { promise.reject("E_AWAIT_PROMISE", "Another async call to this method is in progress. Await the first Promise.") + return } promiseNotYetResolved = promise UIApplication.shared.registerForRemoteNotifications() @@ -57,4 +37,15 @@ public class PushTokenModule: Module { } .runOnQueue(.main) } + + public func didRegister(_ deviceToken: String) { + promiseNotYetResolved?.resolve(deviceToken) + promiseNotYetResolved = nil + self.sendEvent(onDevicePushTokenEventName, ["devicePushToken": deviceToken]) + } + + public func didFailRegistration(_ error: any Error) { + promiseNotYetResolved?.reject(error) + promiseNotYetResolved = nil + } }