mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
22d14ff8e4
* create DependenciesProtocol and replace ServiceLocator registration with it replace mocking calls for ServiceLocator to Dependencies replace ServiceLocator with instance level calls replace ServiceLocator configs with TestDependencies config in Tests replace servicelocator usage in analytics service replace remaining ServiceLocator calls in unit tests that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances fix swiftlint warning remove remaining references to ServiceLocator make dependencies properties consistent in tests delete Dependencies and DependenciesProtocol fix whitespace modifications updates from PR feedback a pass over converting AppSettings and AnalyticsService test instance properties to per test instantiation fix a missed PR feedback Sync up after rebase create DependenciesProtocol and replace ServiceLocator registration with it replace mocking calls for ServiceLocator to Dependencies replace ServiceLocator with instance level calls replace ServiceLocator configs with TestDependencies config in Tests replace servicelocator usage in analytics service replace remaining ServiceLocator calls in unit tests that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances fix swiftlint warning remove remaining references to ServiceLocator make dependencies properties consistent in tests delete Dependencies and DependenciesProtocol * AppSettings is instance level mockable now * replace mocked and preview usages of reset + AppSettings global init with AppSettings(store: UserDefaultsMock()) * AppSettings.UserDefaultsKeys is now a RawRep struct, making it extensible * update tests with new AppSettings so it builds * update remaining AppSettings test/mock inits * AppSettings.UserDefaultsKey conformed to ExpressibleByStringLiteral * addressed issues found in first pass ai review * update AppSettings instantiations after rebase * fix accessibility tests coordinator after rebase * Rename to VolatileUserDefaults * AppSettings init uses mandatory parameter * Revert UserDefaultsKey to enum * Add TrackedUserDefaults to be able to reset the defaults object * Added volatile static func to AppSettings * Use .volatile() static func instead of full init * review fixups * Resolve build issues after rebase * updated enterprise module * remove the static reset settings call from the preview tests --------- Co-authored-by: Mauro Romito <mauro.romito@element.io>
274 lines
14 KiB
Swift
274 lines
14 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import MatrixRustSDK
|
|
import Synchronization
|
|
@preconcurrency 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.
|
|
|
|
final class NotificationServiceExtension: UNNotificationServiceExtension {
|
|
let actor: NotificationServiceExtensionActor
|
|
|
|
override init() {
|
|
actor = NotificationServiceExtensionActor()
|
|
super.init()
|
|
}
|
|
|
|
/// This wouldn't be allowed normally because we added `@Sendable` to the contentHandler which is not by default, however there's nothing we can do about it
|
|
/// since the `UNNotificationServiceExtension` does not support swift 6 concurency yet, so we need to use the `@preconcurrency import` to suppress
|
|
/// this error, and then we handle all the concurrency logic inside the actor that the NSE delegates all the logic to.
|
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping @Sendable (UNNotificationContent) -> Void) {
|
|
let actor = actor
|
|
Task { await actor.handle(request, withContentHandler: contentHandler) }
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
actor.serviceExtensionTimeWillExpire()
|
|
}
|
|
}
|
|
|
|
actor NotificationServiceExtensionActor {
|
|
static let receivedWhileOfflineNotificationID = "io.element.elementx.receivedWhileOfflineNotification"
|
|
|
|
/// Process-wide target configuration, `Mutex`-protected so it can be read/written from any
|
|
/// thread (in particular, from the actor's synchronous `init` without spawning a Task).
|
|
private static let targetConfiguration = Mutex<Target.ConfigurationResult?>(nil)
|
|
|
|
@MainActor
|
|
private static var hasHandledFirstNotificationSinceBoot = false
|
|
|
|
private static let firstNotificationThreshold: TimeInterval = 15 * 60
|
|
|
|
private let settings: CommonSettingsProtocol
|
|
private let appHooks: AppHooks
|
|
|
|
/// This is nonisolated just because the expire function needs to be served ASAP so we should not call it from a Task, however this is
|
|
/// always written and read in the actor isolated context, and only read nonisolated in `serviceExtensionTimeWillExpire` function
|
|
/// which is an acceptable compromise since it's a failure state, and the race condition will not make it worse and present a partial notification regardless.
|
|
private nonisolated(unsafe) var notificationHandler: NotificationHandler?
|
|
private let keychainController = KeychainController(service: .sessions,
|
|
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
|
|
|
deinit {
|
|
ExtensionLogger.logMemory(with: tag)
|
|
MXLog.info("\(tag) deinit")
|
|
}
|
|
|
|
init() {
|
|
guard let userDefaults = TrackedUserDefaults(suiteName: AppSettings.suiteName) else {
|
|
fatalError("Catastrophic error retrieving user defaults for \(AppSettings.suiteName)")
|
|
}
|
|
settings = AppSettings(store: userDefaults)
|
|
|
|
appHooks = AppHooks()
|
|
appHooks.setUp()
|
|
|
|
// If the device is still locked then we can't write to the app group container and
|
|
// the target configuration will fail. We could call exit(0) here, however with the
|
|
// notification filtering entitlement that results in the notification being discarded
|
|
// so we need to wait for the delegate method to be called and bail out there instead.
|
|
// The `Mutex` guarantees `Target.nse.configure(...)` runs at most once per process even
|
|
// under concurrent NSE instance creation.
|
|
if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory) {
|
|
Self.targetConfiguration.withLock { state in
|
|
guard state == nil else { return }
|
|
state = Target.nse.configure(logLevel: settings.logLevel,
|
|
traceLogPacks: settings.traceLogPacks,
|
|
sentryURL: nil,
|
|
rageshakeURL: settings.bugReportRageshakeURL,
|
|
appHooks: appHooks)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async {
|
|
// If we skipped configuring the target it means we can't write to the app group, so we're unlikely to
|
|
// be able to create a session (and even if we could, we would be missing the lightweightTokioRuntime).
|
|
// Additionally, APNs servers only store the most recent notification when the device is powered off.
|
|
// So lets a) skip processing the notification and b) deliver a special "offline" notification as a workaround.
|
|
guard Self.targetConfiguration.withLock({ $0 != nil }) else {
|
|
// MXLog isn't configured:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Device is locked after reboot.")
|
|
|
|
if await Self.hasHandledFirstNotificationSinceBoot {
|
|
return contentHandler(request.content)
|
|
} else {
|
|
await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
|
|
deliverReceivedWhileOfflineNotification(for: request)
|
|
return contentHandler(.init())
|
|
}
|
|
}
|
|
|
|
guard await !shouldDeliverReceivedWhileOfflineNotification() else {
|
|
// Don't log until the app hooks have been run:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Device is unlocked but may have missed notifications while offline.")
|
|
deliverReceivedWhileOfflineNotification(for: request)
|
|
return contentHandler(.init())
|
|
}
|
|
|
|
guard let roomID = request.content.roomID else {
|
|
// Don't log until the app hooks have been run:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Missing roomID, bailing out.")
|
|
return contentHandler(request.content)
|
|
}
|
|
|
|
guard let eventID = request.content.eventID else {
|
|
// Don't log until the app hooks have been run:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Missing eventID, bailing out.")
|
|
return contentHandler(request.content)
|
|
}
|
|
|
|
guard let clientID = request.content.pusherNotificationClientIdentifier else {
|
|
// Don't log until the app hooks have been run:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Missing clientID, bailing out.")
|
|
return contentHandler(request.content)
|
|
}
|
|
|
|
guard let credentials = keychainController.restorationTokens().first(where: { $0.restorationToken.pusherNotificationClientIdentifier == clientID }) else {
|
|
// Don't log until the app hooks have been run:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Credentials not found, bailing out.")
|
|
return contentHandler(request.content)
|
|
}
|
|
|
|
let homeserverURL = credentials.restorationToken.session.homeserverUrl
|
|
await appHooks.remoteSettingsHook.loadCache(forHomeserver: homeserverURL, applyingTo: settings)
|
|
|
|
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
return contentHandler(request.content)
|
|
}
|
|
|
|
MXLog.info("\(tag) #########################################")
|
|
|
|
ExtensionLogger.logMemory(with: tag)
|
|
|
|
MXLog.info("\(tag) Received payload: \(request.content.userInfo)")
|
|
|
|
do {
|
|
let userSession = try await NSEUserSession(credentials: credentials,
|
|
roomID: roomID,
|
|
clientSessionDelegate: keychainController,
|
|
appHooks: appHooks,
|
|
appSettings: settings)
|
|
|
|
notificationHandler = NotificationHandler(userSession: userSession,
|
|
settings: settings,
|
|
contentHandler: contentHandler,
|
|
notificationContent: mutableContent,
|
|
tag: tag)
|
|
|
|
ExtensionLogger.logMemory(with: tag)
|
|
MXLog.info("\(tag) Configured user session")
|
|
|
|
await notificationHandler?.processEvent(eventID, roomID: roomID)
|
|
} catch {
|
|
MXLog.error("Failed creating user session with error: \(error)")
|
|
}
|
|
}
|
|
|
|
nonisolated func serviceExtensionTimeWillExpire() {
|
|
notificationHandler?.handleTimeExpiration()
|
|
}
|
|
|
|
// MARK: - Boot handling
|
|
|
|
/// The APNs servers only store the most recent notification when delivery fails. So when the user first boots
|
|
/// their phone we need to use some approximations to decide whether or not the first notification may potentially
|
|
/// represent more than one message. When that appears possible we replace the notification's content with the special
|
|
/// "received while offline" notification as a more prominent prompt for to the user to open the app and check all their chats.
|
|
///
|
|
/// Note that this only handles the first-boot case. When the SDK is able to compute the unread count, we should start to use the NSE,
|
|
/// remote-notifications (content-available) and background app refreshes to fetch and deliver our notifications as a more robust solution.
|
|
private func shouldDeliverReceivedWhileOfflineNotification() async -> Bool {
|
|
if await Self.hasHandledFirstNotificationSinceBoot {
|
|
// If we've already handled the first notification in this process there's no need to continue.
|
|
return false
|
|
}
|
|
|
|
await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
|
|
|
|
guard let currentBootTime = BootDetectionManager.systemBootTime() else {
|
|
// There's not much we can do if the boot time is unknown, so don't show the offline notification.
|
|
return false
|
|
}
|
|
|
|
guard let lastKnownBootTime = settings.lastNotificationBootTime else {
|
|
// Assume a missing boot time indicates a fresh installation…
|
|
// So store the current boot time but let the notification through.
|
|
settings.lastNotificationBootTime = currentBootTime
|
|
return false
|
|
}
|
|
|
|
if abs(lastKnownBootTime - currentBootTime) < 1 {
|
|
return false
|
|
}
|
|
|
|
// This is the first notification since boot, store the boot time.
|
|
settings.lastNotificationBootTime = currentBootTime
|
|
|
|
// At this point it becomes a trade-off. Once the device has been powered on for a long enough amount
|
|
// of time it is a reasonable assumption that the device has now connected to a network and that any
|
|
// notification is actually new rather than having been sent whilst the device was powered off.
|
|
//
|
|
// Note: We could actually solve this by having Sygnal add a timestamp to the notification payload 🤔
|
|
if Date.now.timeIntervalSince(Date(timeIntervalSince1970: currentBootTime)) > Self.firstNotificationThreshold {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Delivers a generic notification informing the user that they have one or more new messages.
|
|
///
|
|
/// Note: it is safe to call this method multiple times as it simply replaces any existing instance of the notification
|
|
/// with a fresh copy, meaning it won't queue multiple copies but will still re-play the notification sound.
|
|
private func deliverReceivedWhileOfflineNotification(for originalRequest: UNNotificationRequest) {
|
|
// This is intended to be called before the app hooks have been run, so don't log:
|
|
// swiftlint:disable:next print_deprecation
|
|
print("Delivering the 'received while offline' notification.")
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.body = L10n.notificationReceivedWhileOfflineIos
|
|
content.badge = originalRequest.content.unreadCount as NSNumber?
|
|
content.sound = settings.notificationSound
|
|
|
|
let request = UNNotificationRequest(identifier: Self.receivedWhileOfflineNotificationID, content: content, trigger: nil)
|
|
UNUserNotificationCenter.current().add(request)
|
|
}
|
|
|
|
// MARK: - Logging
|
|
|
|
private nonisolated var tag: String {
|
|
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]"
|
|
}
|
|
}
|