diff --git a/native/ios/Comm/AppDelegate.mm b/native/ios/Comm/AppDelegate.mm index 3367f8c60..be80692eb 100644 --- a/native/ios/Comm/AppDelegate.mm +++ b/native/ios/Comm/AppDelegate.mm @@ -1,346 +1,378 @@ #import "AppDelegate.h" #import #import #import #import #if RCT_NEW_ARCH_ENABLED #import #import #import #import #import #import #import static NSString *const kRNConcurrentRoot = @"concurrentRoot"; @interface AppDelegate () < RCTCxxBridgeDelegate, RCTTurboModuleManagerDelegate> { RCTTurboModuleManager *_turboModuleManager; RCTSurfacePresenterBridgeAdapter *_bridgeAdapter; std::shared_ptr _reactNativeConfig; facebook::react::ContextContainer::Shared _contextContainer; } @end #endif #import "CommIOSNotifications.h" #import "Orientation.h" #import #import #import #import #import #import #import #import "CommCoreModule.h" #import "GlobalDBSingleton.h" #import "Logger.h" #import "MessageOperationsUtilities.h" #import "TemporaryMessageStorage.h" #import "ThreadOperations.h" #import "Tools.h" #import #import #import #import #import #import NSString *const backgroundNotificationTypeKey = @"backgroundNotifType"; NSString *const setUnreadStatusKey = @"setUnreadStatus"; +NSString *const threadIDKey = @"threadID"; @interface AppDelegate () < RCTCxxBridgeDelegate, RCTTurboModuleManagerDelegate> { } @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [self attemptDatabaseInitialization]; return YES; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { RCTAppSetupPrepareApp(application); [self moveMessagesToDatabase]; [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil]; RCTBridge *bridge = [self.reactDelegate createBridgeWithDelegate:self launchOptions:launchOptions]; #if RCT_NEW_ARCH_ENABLED _contextContainer = std::make_shared(); _reactNativeConfig = std::make_shared(); _contextContainer->insert("ReactNativeConfig", _reactNativeConfig); _bridgeAdapter = [[RCTSurfacePresenterBridgeAdapter alloc] initWithBridge:bridge contextContainer:_contextContainer]; bridge.surfacePresenter = _bridgeAdapter.surfacePresenter; #endif NSDictionary *initProps = [self prepareInitialProps]; UIView *rootView = [self.reactDelegate createRootViewWithBridge:bridge moduleName:@"Comm" initialProperties:initProps]; if (@available(iOS 13.0, *)) { rootView.backgroundColor = [UIColor systemBackgroundColor]; } else { rootView.backgroundColor = [UIColor whiteColor]; } self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [self.reactDelegate createRootViewController]; rootViewController.view = rootView; self.window.rootViewController = rootViewController; [self.window makeKeyAndVisible]; [super application:application didFinishLaunchingWithOptions:launchOptions]; // This prevents a very small flicker from occurring before expo-splash-screen // is able to display UIView *launchScreenView = [[UIStoryboard storyboardWithName:@"SplashScreen" bundle:nil] instantiateInitialViewController] .view; launchScreenView.frame = self.window.bounds; ((RCTRootView *)rootView).loadingView = launchScreenView; ((RCTRootView *)rootView).loadingViewFadeDelay = 0; ((RCTRootView *)rootView).loadingViewFadeDuration = 0.001; return YES; } - (NSArray> *)extraModulesForBridge:(RCTBridge *)bridge { // If you'd like to export some custom RCTBridgeModules that are not Expo // modules, add them here! return @[]; } /// This method controls whether the `concurrentRoot`feature of React18 is /// turned on or off. /// /// @see: https://reactjs.org/blog/2022/03/29/react-v18.html /// @note: This requires to be rendering on Fabric (i.e. on the New /// Architecture). /// @return: `true` if the `concurrentRoot` feture is enabled. Otherwise, it /// returns `false`. - (BOOL)concurrentRootEnabled { // Switch this bool to turn on and off the concurrent root return true; } - (NSDictionary *)prepareInitialProps { NSMutableDictionary *initProps = [NSMutableDictionary new]; #ifdef RCT_NEW_ARCH_ENABLED initProps[kRNConcurrentRoot] = @([self concurrentRootEnabled]); #endif return initProps; } - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { [CommIOSNotifications didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; } - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { [CommIOSNotifications didFailToRegisterForRemoteNotificationsWithError:error]; } // Required for the notification event. You must call the completion handler // after handling the remote notification. - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)notification fetchCompletionHandler: (void (^)(UIBackgroundFetchResult))completionHandler { BOOL handled = NO; if (notification[@"aps"][@"content-available"] && notification[backgroundNotificationTypeKey]) { handled = [self handleBackgroundNotification:notification fetchCompletionHandler:completionHandler]; } if (handled) { return; } [CommIOSNotifications didReceiveRemoteNotification:notification fetchCompletionHandler:completionHandler]; } - (BOOL)handleBackgroundNotification:(NSDictionary *)notification fetchCompletionHandler: (void (^)(UIBackgroundFetchResult))completionHandler { if ([notification[backgroundNotificationTypeKey] isEqualToString:@"CLEAR"]) { if (notification[setUnreadStatusKey] && notification[@"threadID"]) { std::string threadID = std::string([notification[@"threadID"] UTF8String]); // this callback may be called from inactive state so we need // to initialize the database [self attemptDatabaseInitialization]; comm::GlobalDBSingleton::instance.scheduleOrRun([threadID]() mutable { comm::ThreadOperations::updateSQLiteUnreadStatus(threadID, false); }); } [CommIOSNotifications clearNotificationFromNotificationsCenter:notification[@"notificationId"] completionHandler:completionHandler]; return YES; } return NO; } - (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { return [Orientation getOrientation]; } - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; #else return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; #endif } #if RCT_NEW_ARCH_ENABLED #pragma mark - RCTCxxBridgeDelegate - (std::unique_ptr) jsExecutorFactoryForBridge:(RCTBridge *)bridge { _turboModuleManager = [[RCTTurboModuleManager alloc] initWithBridge:bridge delegate:self jsInvoker:bridge.jsCallInvoker]; return RCTAppSetupDefaultJsExecutorFactory(bridge, _turboModuleManager); } #pragma mark RCTTurboModuleManagerDelegate - (Class)getModuleClassFromName:(const char *)name { return RCTCoreModulesClassProvider(name); } - (std::shared_ptr) getTurboModule:(const std::string &)name jsInvoker:(std::shared_ptr)jsInvoker { return nullptr; } - (std::shared_ptr) getTurboModule:(const std::string &)name initParams: (const facebook::react::ObjCTurboModule::InitParams &)params { return nullptr; } - (id)getModuleInstanceFromClass:(Class)moduleClass { return RCTAppSetupDefaultModuleFromClass(moduleClass); } #endif using JSExecutorFactory = facebook::react::JSExecutorFactory; using HermesExecutorFactory = facebook::react::HermesExecutorFactory; using Runtime = facebook::jsi::Runtime; - (std::unique_ptr)jsExecutorFactoryForBridge: (RCTBridge *)bridge { __weak __typeof(self) weakSelf = self; const auto commRuntimeInstaller = [weakSelf, bridge](facebook::jsi::Runtime &rt) { if (!bridge) { return; } __typeof(self) strongSelf = weakSelf; if (strongSelf) { std::shared_ptr nativeModule = std::make_shared(bridge.jsCallInvoker); rt.global().setProperty( rt, facebook::jsi::PropNameID::forAscii(rt, "CommCoreModule"), facebook::jsi::Object::createFromHostObject(rt, nativeModule)); } }; const auto installer = reanimated::REAJSIExecutorRuntimeInstaller(bridge, commRuntimeInstaller); return std::make_unique( facebook::react::RCTJSIExecutorRuntimeInstaller(installer), JSIExecutor::defaultTimeoutInvoker, makeRuntimeConfig(3072)); } - (void)attemptDatabaseInitialization { std::string sqliteFilePath = std::string([[Tools getSQLiteFilePath] UTF8String]); // Previous Comm versions used app group location for SQLite // database, so that NotificationService was able to acces it directly. // Unfortunately it caused errores related to system locks. The code // below re-migrates SQLite from app group to app specific location // on devices where previous Comm version was installed. NSString *appGroupSQLiteFilePath = [Tools getAppGroupSQLiteFilePath]; if ([NSFileManager.defaultManager fileExistsAtPath:appGroupSQLiteFilePath] && std::rename( std::string([appGroupSQLiteFilePath UTF8String]).c_str(), sqliteFilePath.c_str())) { throw std::runtime_error( "Failed to move SQLite database from app group to default location"); } comm::GlobalDBSingleton::instance.scheduleOrRun([&sqliteFilePath]() { comm::DatabaseManager::initializeQueryExecutor(sqliteFilePath); }); } - (void)moveMessagesToDatabase { TemporaryMessageStorage *temporaryStorage = [[TemporaryMessageStorage alloc] init]; NSArray *messages = [temporaryStorage readAndClearMessages]; for (NSString *message in messages) { std::string messageInfos = std::string([message UTF8String]); comm::GlobalDBSingleton::instance.scheduleOrRun([messageInfos]() mutable { comm::MessageOperationsUtilities::storeMessageInfos(messageInfos); }); } + + TemporaryMessageStorage *temporaryRescindsStorage = + [[TemporaryMessageStorage alloc] initForRescinds]; + NSArray *rescindMessages = + [temporaryRescindsStorage readAndClearMessages]; + for (NSString *rescindMessage in rescindMessages) { + NSData *binaryRescindMessage = + [rescindMessage dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *jsonError = nil; + NSDictionary *rescindPayload = + [NSJSONSerialization JSONObjectWithData:binaryRescindMessage + options:0 + error:&jsonError]; + if (jsonError) { + comm::Logger::log( + "Failed to deserialize persisted rescind payload. Details: " + + std::string([jsonError.localizedDescription UTF8String])); + continue; + } + + if (!(rescindPayload[setUnreadStatusKey] && rescindPayload[threadIDKey])) { + continue; + } + + std::string threadID = + std::string([rescindPayload[threadIDKey] UTF8String]); + comm::GlobalDBSingleton::instance.scheduleOrRun([threadID]() mutable { + comm::ThreadOperations::updateSQLiteUnreadStatus(threadID, false); + }); + } } // Copied from // ReactAndroid/src/main/java/com/facebook/hermes/reactexecutor/OnLoad.cpp static ::hermes::vm::RuntimeConfig makeRuntimeConfig(::hermes::vm::gcheapsize_t heapSizeMB) { namespace vm = ::hermes::vm; auto gcConfigBuilder = vm::GCConfig::Builder() .withName("RN") // For the next two arguments: avoid GC before TTI by initializing the // runtime to allocate directly in the old generation, but revert to // normal operation when we reach the (first) TTI point. .withAllocInYoung(false) .withRevertToYGAtTTI(true); if (heapSizeMB > 0) { gcConfigBuilder.withMaxHeapSize(heapSizeMB << 20); } return vm::RuntimeConfig::Builder() .withGCConfig(gcConfigBuilder.build()) .build(); } @end diff --git a/native/ios/NotificationService/NotificationService.mm b/native/ios/NotificationService/NotificationService.mm index 1187e0201..99c6aa853 100644 --- a/native/ios/NotificationService/NotificationService.mm +++ b/native/ios/NotificationService/NotificationService.mm @@ -1,93 +1,123 @@ #import "NotificationService.h" #import "Logger.h" #import "TemporaryMessageStorage.h" NSString *const backgroundNotificationTypeKey = @"backgroundNotifType"; // The context for this constant can be found here: // https://linear.app/comm/issue/ENG-3074#comment-bd2f5e28 int64_t const notificationRemovalDelay = (int64_t)(0.1 * NSEC_PER_SEC); @interface NotificationService () @property(nonatomic, strong) void (^contentHandler) (UNNotificationContent *contentToDeliver); @property(nonatomic, strong) UNMutableNotificationContent *bestAttemptContent; @end @implementation NotificationService - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler: (void (^)(UNNotificationContent *_Nonnull)) contentHandler { self.contentHandler = contentHandler; self.bestAttemptContent = [request.content mutableCopy]; + [self persistMessagePayload:self.bestAttemptContent.userInfo]; + // Message payload persistence is a higher priority task, so it has + // to happen prior to potential notification center clearing. if ([self isRescind:self.bestAttemptContent.userInfo]) { [self removeNotificationWithIdentifier:self.bestAttemptContent .userInfo[@"notificationId"]]; self.contentHandler([[UNNotificationContent alloc] init]); return; } - NSString *message = self.bestAttemptContent.userInfo[@"messageInfos"]; - if (message) { - TemporaryMessageStorage *temporaryStorage = - [[TemporaryMessageStorage alloc] init]; - [temporaryStorage writeMessage:message]; - } // TODO modify self.bestAttemptContent here self.contentHandler(self.bestAttemptContent); } - (void)serviceExtensionTimeWillExpire { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified // content, otherwise the original push payload will be used. if ([self isRescind:self.bestAttemptContent.userInfo]) { // If we get to this place it means we were unable to // remove relevant notification from notification center in // in time given to NSE to process notification. // It is an extremely unlikely to happen. comm::Logger::log("NSE: Exceeded time limit to rescind a notification."); self.contentHandler([[UNNotificationContent alloc] init]); } self.contentHandler(self.bestAttemptContent); } - (void)removeNotificationWithIdentifier:(NSString *)identifier { dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); void (^delayedSemaphorePostCallback)() = ^() { dispatch_time_t timeToPostSemaphore = dispatch_time(DISPATCH_TIME_NOW, notificationRemovalDelay); dispatch_after(timeToPostSemaphore, dispatch_get_main_queue(), ^{ dispatch_semaphore_signal(semaphore); }); }; [UNUserNotificationCenter.currentNotificationCenter getDeliveredNotificationsWithCompletionHandler:^( NSArray *_Nonnull notifications) { for (UNNotification *notif in notifications) { if ([identifier isEqual:notif.request.content.userInfo[@"id"]]) { [UNUserNotificationCenter.currentNotificationCenter removeDeliveredNotificationsWithIdentifiers:@[ notif.request.identifier ]]; } } delayedSemaphorePostCallback(); }]; dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); } +- (void)persistMessagePayload:(NSDictionary *)payload { + if (payload[@"messageInfos"]) { + TemporaryMessageStorage *temporaryStorage = + [[TemporaryMessageStorage alloc] init]; + [temporaryStorage writeMessage:payload[@"messageInfos"]]; + return; + } + + if (![self isRescind:payload]) { + return; + } + + NSError *jsonError = nil; + NSData *binarySerializedRescindPayload = + [NSJSONSerialization dataWithJSONObject:payload + options:0 + error:&jsonError]; + if (jsonError) { + comm::Logger::log( + "NSE: Failed to serialize rescind payload. Details: " + + std::string([jsonError.localizedDescription UTF8String])); + return; + } + + NSString *serializedRescindPayload = + [[NSString alloc] initWithData:binarySerializedRescindPayload + encoding:NSUTF8StringEncoding]; + + TemporaryMessageStorage *temporaryRescindsStorage = + [[TemporaryMessageStorage alloc] initForRescinds]; + [temporaryRescindsStorage writeMessage:serializedRescindPayload]; +} + - (BOOL)isRescind:(NSDictionary *)payload { return payload[backgroundNotificationTypeKey] && [payload[backgroundNotificationTypeKey] isEqualToString:@"CLEAR"]; } @end