mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
a520d94760
* Enable Swift 6.2 with default main actor isolation for the app target Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default). # Conflicts: # ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved * Update the code generation templates for Swift 6 Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings. # Conflicts: # ElementX/Sources/Mocks/Generated/GeneratedMocks.swift * Make the data layer and services nonisolated and Sendable Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation. * Adopt the new isolation across call sites, mocks and signatures Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms. ^ Conflicts: ^ ElementX/Sources/Services/SecureBackup/SecureBackupController.swift * Remove redundant @MainActor annotations With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required. * Fix concurrency hotspots surfaced by the migration Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs. * Fix the unit tests for Swift 6 concurrency Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures. * offload diffs on concurrent threads instead of main actor * make room info protocols sendable * use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview. * use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor. * small pr comments issues being addressed * update enterprise * Update StaticCode to 1.1.0 and specify the required Swift isolation. * fix ElementCallServiceTests * update enterprise * re-generate mocks * update enterprise * enterprise new code conformed to swift 6 --------- Co-authored-by: Doug <douglase@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 nonisolated 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)]"
|
|
}
|
|
}
|