// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import UIKit import UserNotifications // The lifecycle of the NSE looks something like the following: // 1) App receives notification // 2) System creates an instance of the extension class // and calls `didReceive` in the background // 3) Extension processes messages / displays whatever // notifications it needs to // 4) Extension notifies its work is complete by calling // the contentHandler // 5) If the extension takes too long to perform its work // (more than 30s), it will be notified and immediately // terminated // // Note that the NSE does *not* always spawn a new process to // handle a new notification and will also try and process notifications // in parallel. `didReceive` could be called twice for the same process, // but it will always be called on different threads. It may or may not be // called on the same instance of `NotificationService` as a previous // notification. // // We keep a global `environment` singleton to ensure that our app context, // database, logging, etc. are only ever setup once per *process* private let globalEnvironment = NSEEnvironment() @MainActor private var hasShownFirstUnlockError = false class NotificationService: UNNotificationServiceExtension { private typealias ContentHandler = (UNNotificationContent) -> Void private let contentHandler = AtomicOptional(nil, lock: .init()) private let fetchQueue = SerialTaskQueue() // MARK: - private static let unfairLock = UnfairLock() private static var _logTimer: OffMainThreadTimer? private static var _nseCounter: Int = 0 private static func nseDidStart() -> Int { unfairLock.withLock { if DebugFlags.internalLogging, _logTimer == nil { _logTimer = OffMainThreadTimer(timeInterval: 1.0, repeats: true) { _ in NSELogger.uncorrelated.info("... memoryUsage: \(LocalDevice.memoryUsageString)") } } _nseCounter += 1 return _nseCounter } } private static func nseDidComplete() { unfairLock.withLock { _nseCounter = _nseCounter > 0 ? _nseCounter - 1 : 0 if _nseCounter == 0, _logTimer != nil { _logTimer?.invalidate() _logTimer = nil } } } // MARK: - // This method is thread-safe. func completeSilently(content: UNNotificationContent, logger: NSELogger) { defer { logger.flush() } guard let contentHandler = contentHandler.swap(nil) else { return } Self.nseDidComplete() contentHandler(content) } override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void, ) { let logger = NSELogger() _ = Self.nseDidStart() self.contentHandler.set(contentHandler) self.fetchQueue.enqueueCancellingPrevious { let content = await self._didReceive(request, logger: logger) self.completeSilently(content: content, logger: logger) } } private func verificationCodeRequestTimestampMs(userInfo: [AnyHashable: Any]) -> UInt64? { return (userInfo["verificationCodeRequested"] as? [String: Any])?["timestamp"] as? UInt64 } @MainActor private func _didReceive(_ request: UNNotificationRequest, logger: NSELogger) async -> UNNotificationContent { globalEnvironment.setUp(logger: logger) let finalContinuation: AppSetup.FinalContinuation do { finalContinuation = try await globalEnvironment.setUpDatabase(logger: logger) } catch KeychainError.notAllowed { // Detect and handle "no GRDB file" and "no keychain access". if !hasShownFirstUnlockError { hasShownFirstUnlockError = true logger.error("DB Keys not accessible; showing error.") logger.flush() let content = UNMutableNotificationContent() let notificationFormat = OWSLocalizedString( "NOTIFICATION_BODY_PHONE_LOCKED_FORMAT", comment: "Lock screen notification text presented after user powers on their device without unlocking. Embeds {{device model}} (either 'iPad' or 'iPhone')", ) content.body = String.nonPluralLocalizedStringWithFormat(notificationFormat, UIDevice.current.localizedModel) return content } else { // Only show a single error if we receive multiple pushes // before first device unlock. logger.error("DB Keys not accessible; completing silently.") logger.flush() let emptyContent = UNMutableNotificationContent() return emptyContent } } catch { owsFail("Couldn't load database: \(error.grdbErrorForLogging)") } // Re-warm the caches each time to pick up changes made by the main app. finalContinuation.runLaunchTasksIfNeededAndReloadCaches() // Re-set up the local identifiers to ensure they're propagated throughout the system. switch finalContinuation.setUpLocalIdentifiers( willResumeInProgressRegistration: false, canInitiateRegistration: false, ) { case .corruptRegistrationState: Logger.warn("Ignoring request to process notifications when the user isn't registered.") return UNNotificationContent() case nil: globalEnvironment.setAppIsReady() } if let timestamp = verificationCodeRequestTimestampMs(userInfo: request.content.userInfo) { await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isPrimaryDevice == true else { Logger.info("Received verification code push on non-primary device; ignoring.") return } let kvStore = SafetyTipsManager() kvStore.setLastVerificationCodeRequestedTimestampMs(value: timestamp, transaction: transaction) } Logger.info("Skipping fetch for verification code requested push") return UNNotificationContent() } // Mark down that the APNS token is working since we got a push. // Do this as early as possible but after the app is ready and has run // GRDB migrations and such. async let didMarkApnsReceived: Void = SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in APNSRotationStore.didReceiveAPNSPush(transaction: transaction) } let result = await self.fetchAndProcessMessages(logger: logger) await didMarkApnsReceived return result } // Called just before the extension will be terminated by the system. override func serviceExtensionTimeWillExpire() { Logger.warn("Canceling fetchingQueue tasks because we ran out of time.") self.fetchQueue.cancelAll() // We complete silently here so that nothing is presented to the user. // By default the OS will present whatever the raw content of the original // notification is to the user otherwise. completeSilently(content: UNMutableNotificationContent(), logger: .uncorrelated) } private func startProxyIfEnabled() async throws(CancellationError) { if SignalProxy.isEnabled { Logger.info("Waiting for signal proxy to become ready for message fetch.") SignalProxy.startRelayServer() try await Preconditions([ NotificationPrecondition( notificationName: .isSignalProxyReadyDidChange, isSatisfied: { SignalProxy.isEnabledAndReady }, ), ]).waitUntilSatisfied() } } @MainActor private func fetchAndProcessMessages(logger: NSELogger) async -> UNNotificationContent { if DependenciesBridge.shared.appExpiry.isExpired(now: Date()) { Logger.warn("Not processing notifications for expired application.") return UNMutableNotificationContent() } let cron = DependenciesBridge.shared.cron let cronCtx = CronContext( chatConnectionManager: DependenciesBridge.shared.chatConnectionManager, tsAccountManager: DependenciesBridge.shared.tsAccountManager, ) do { try await startProxyIfEnabled() defer { SignalProxy.stopRelayServer() } let backgroundMessageFetcher = DependenciesBridge.shared.backgroundMessageFetcherFactory.buildFetcher() await backgroundMessageFetcher.start() // Start Cron after we request a socket. async let cronResult: Void = cron.runOnce(ctx: cronCtx) let fetchResult = await Result(catching: { try await backgroundMessageFetcher.waitForFetchingProcessingAndSideEffects() }) // Wait for Cron to finish executing before we release the socket. await cronResult await backgroundMessageFetcher.stopAndWaitBeforeSuspending() try fetchResult.get() } catch is CancellationError { Logger.warn("Message fetching & processing canceled.") return UNMutableNotificationContent() } catch { Logger.warn("\(error)") } logger.info("Message fetching & processing completed.") // If we're completing normally, try to update the badge on the app icon. let badgeCount: BadgeCount = SSKEnvironment.shared.databaseStorageRef.read { tx in return DependenciesBridge.shared.badgeCountFetcher .fetchBadgeCount(tx: tx) } let content = UNMutableNotificationContent() content.badge = NSNumber(value: badgeCount.unreadTotalCount) return content } }