Swift 6 migration for NSE (#5608)

* swift 6 support for NSE and concurrency safe implementations for it

* enforcing required Sendable conformance for swift 6 migration in NSE

* use Mutex to access the string builder cache

* use an async stream to update timeline items diffs

* fix for Sendable appHooks
This commit is contained in:
Mauro
2026-05-21 20:21:57 +02:00
committed by GitHub
parent 91ccff36fc
commit 2462ddea85
20 changed files with 226 additions and 150 deletions
+4 -4
View File
@@ -906,7 +906,6 @@
94D9172C4B91FC6971C668CE /* LeaveSpaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE4B7AD74E402BBC46A801A /* LeaveSpaceView.swift */; };
94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3A7375AB22721C436EB056 /* ComposerToolbarModels.swift */; };
94EB81606839C175CA6C2ADB /* RoomScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97A4E73EA97CA08D2BB9806 /* RoomScreenTests.swift */; };
94F0B78928E952689ACDB271 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */; };
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; };
@@ -1448,7 +1447,6 @@
F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57916A1578D8043BB0795441 /* GeneratedMocks.swift */; };
F1C68F64FC8A66B6B9510BF7 /* ManageRoomMemberSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B30CD19ED6243FEDFBA8400 /* ManageRoomMemberSheetView.swift */; };
F252C0EA49088801F4CA6006 /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 96CE9D6642DD487D8CC90C9C /* landscape_test_image.jpg */; };
F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; };
F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; };
F2D5C0E1351DA7BD16867629 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */; };
F2E580C0FBFBEFFE9D69893B /* RoomPreviewProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */; };
@@ -7796,7 +7794,6 @@
68C3AF257678F6E7BB238C3F /* AppAppearance.swift in Sources */,
A2172B5A26976F9174228B8A /* AppHooks.swift in Sources */,
E1DE8A5422643F759385290F /* AppSettings.swift in Sources */,
F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */,
484202C5D50983442D24D061 /* AttributedString.swift in Sources */,
CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */,
BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */,
@@ -7835,7 +7832,6 @@
E2DB696117BAEABAD5718023 /* MediaSourceProxy.swift in Sources */,
4CB30D9BD72CA3FEEFAA0793 /* NSEUserSession.swift in Sources */,
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */,
94F0B78928E952689ACDB271 /* NetworkMonitor.swift in Sources */,
4DEEFB73181C3B023DB42686 /* NetworkMonitorProtocol.swift in Sources */,
5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */,
2689D22EF1D10D22B0A4DAEA /* NotificationContentBuilder.swift in Sources */,
@@ -9545,7 +9541,9 @@
PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)";
PRODUCT_NAME = NSE;
SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h";
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -9926,7 +9924,9 @@
PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)";
PRODUCT_NAME = NSE;
SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h";
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
+63 -22
View File
@@ -7,66 +7,107 @@
//
import Foundation
import Synchronization
class AppHooks: AppHooksProtocol {
final class AppHooks: AppHooksProtocol {
#if IS_MAIN_APP
func configure(with userSession: UserSessionProtocol?) async {
await roomScreenHook.configure(with: userSession)
}
private(set) var appSettingsHook: AppSettingsHookProtocol = DefaultAppSettingsHook()
private let _appSettingsHook: Mutex<AppSettingsHookProtocol> = Mutex(DefaultAppSettingsHook())
var appSettingsHook: AppSettingsHookProtocol {
_appSettingsHook.withLock { $0 }
}
func registerAppSettingsHook(_ hook: AppSettingsHookProtocol) {
appSettingsHook = hook
_appSettingsHook.withLock { $0 = hook }
}
private let _compoundHook: Mutex<CompoundHookProtocol> = Mutex(DefaultCompoundHook())
var compoundHook: CompoundHookProtocol {
_compoundHook.withLock { $0 }
}
private(set) var compoundHook: CompoundHookProtocol = DefaultCompoundHook()
func registerCompoundHook(_ hook: CompoundHookProtocol) {
compoundHook = hook
_compoundHook.withLock { $0 = hook }
}
private let _bugReportHook: Mutex<BugReportHookProtocol> = Mutex(DefaultBugReportHook())
var bugReportHook: BugReportHookProtocol {
_bugReportHook.withLock { $0 }
}
private(set) var bugReportHook: BugReportHookProtocol = DefaultBugReportHook()
func registerBugReportHook(_ hook: BugReportHookProtocol) {
bugReportHook = hook
_bugReportHook.withLock { $0 = hook }
}
private let _certificateValidatorHook: Mutex<CertificateValidatorHookProtocol> = Mutex(DefaultCertificateValidator())
var certificateValidatorHook: CertificateValidatorHookProtocol {
_certificateValidatorHook.withLock { $0 }
}
private(set) var certificateValidatorHook: CertificateValidatorHookProtocol = DefaultCertificateValidator()
func registerCertificateValidatorHook(_ hook: CertificateValidatorHookProtocol) {
certificateValidatorHook = hook
_certificateValidatorHook.withLock { $0 = hook }
}
private let _oAuthPresenterHook: Mutex<OAuthPresenterHookProtocol> = Mutex(DefaultOAuthPresenterHook())
var oAuthPresenterHook: OAuthPresenterHookProtocol {
_oAuthPresenterHook.withLock { $0 }
}
private(set) var oAuthPresenterHook: OAuthPresenterHookProtocol = DefaultOAuthPresenterHook()
func registerOAuthPresenterHook(_ hook: OAuthPresenterHookProtocol) {
oAuthPresenterHook = hook
_oAuthPresenterHook.withLock { $0 = hook }
}
private let _roomScreenHook: Mutex<RoomScreenHookProtocol> = Mutex(DefaultRoomScreenHook())
var roomScreenHook: RoomScreenHookProtocol {
_roomScreenHook.withLock { $0 }
}
private(set) var roomScreenHook: RoomScreenHookProtocol = DefaultRoomScreenHook()
func registerRoomScreenHook(_ hook: RoomScreenHookProtocol) {
roomScreenHook = hook
_roomScreenHook.withLock { $0 = hook }
}
private let _developerOptionsScreenHook: Mutex<DeveloperOptionsScreenHookProtocol> = Mutex(DefaultDeveloperOptionsScreenHook())
var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol {
_developerOptionsScreenHook.withLock { $0 }
}
private(set) var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol = DefaultDeveloperOptionsScreenHook()
func registerDeveloperOptionsScreenHook(_ hook: DeveloperOptionsScreenHookProtocol) {
developerOptionsScreenHook = hook
_developerOptionsScreenHook.withLock { $0 = hook }
}
#endif
private(set) var tracingHook: TracingHookProtocol = DefaultTracingHook()
private let _tracingHook: Mutex<TracingHookProtocol> = Mutex(DefaultTracingHook())
var tracingHook: TracingHookProtocol {
_tracingHook.withLock { $0 }
}
func registerTracingHook(_ hook: TracingHookProtocol) {
tracingHook = hook
_tracingHook.withLock { $0 = hook }
}
private let _clientBuilderHook: Mutex<ClientBuilderHookProtocol> = Mutex(DefaultClientBuilderHook())
var clientBuilderHook: ClientBuilderHookProtocol {
_clientBuilderHook.withLock { $0 }
}
private(set) var clientBuilderHook: ClientBuilderHookProtocol = DefaultClientBuilderHook()
func registerClientBuilderHook(_ hook: ClientBuilderHookProtocol) {
clientBuilderHook = hook
_clientBuilderHook.withLock { $0 = hook }
}
private let _remoteSettingsHook: Mutex<RemoteSettingsHookProtocol> = Mutex(DefaultRemoteSettingsHook())
var remoteSettingsHook: RemoteSettingsHookProtocol {
_remoteSettingsHook.withLock { $0 }
}
private(set) var remoteSettingsHook: RemoteSettingsHookProtocol = DefaultRemoteSettingsHook()
func registerRemoteSettingsHook(_ hook: RemoteSettingsHookProtocol) {
remoteSettingsHook = hook
_remoteSettingsHook.withLock { $0 = hook }
}
}
protocol AppHooksProtocol {
protocol AppHooksProtocol: Sendable {
func setUp()
}
@@ -8,7 +8,7 @@
import MatrixRustSDK
protocol ClientBuilderHookProtocol {
protocol ClientBuilderHookProtocol: Sendable {
func configure(_ builder: ClientBuilder) -> ClientBuilder
}
@@ -13,7 +13,7 @@ enum RemoteSettingsError: Error {
case elementProRequired(serverName: String)
}
protocol RemoteSettingsHookProtocol {
protocol RemoteSettingsHookProtocol: Sendable {
#if IS_MAIN_APP
@MainActor func initializeCache(using client: ClientProtocol, applyingTo appSettings: CommonSettingsProtocol) async -> Result<Void, RemoteSettingsError>
func updateCache(using client: ClientProtocol) async
@@ -8,7 +8,7 @@
import MatrixRustSDK
protocol TracingHookProtocol {
protocol TracingHookProtocol: Sendable {
func update(_ configuration: TracingConfiguration, with rageshakeURL: RemotePreference<RageshakeConfiguration>)
}
@@ -14,7 +14,7 @@ import Foundation
import SwiftUI
/// Common settings between app and NSE
protocol CommonSettingsProtocol: AnyObject {
protocol CommonSettingsProtocol: AnyObject, Sendable {
var lastNotificationBootTime: TimeInterval? { get set }
var selectedNotificationTone: NotificationTone? { get set }
@@ -34,7 +34,9 @@ enum AppBuildType {
}
/// Store Element specific app settings.
final class AppSettings {
///
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
final class AppSettings: @unchecked Sendable {
private enum UserDefaultsKeys: String {
case lastVersionLaunched
case seenInvites
@@ -92,10 +94,10 @@ final class AppSettings {
case developerOptionsEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
private nonisolated(unsafe) static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
/// UserDefaults to be used on reads and writes.
private static var store: UserDefaults! = UserDefaults(suiteName: suiteName)
/// UserDefaults to be used on reads and writes. `UserDefaults` is thread-safe per Apple's documentation.
private nonisolated(unsafe) static var store: UserDefaults! = UserDefaults(suiteName: suiteName)
static var appBuildType: AppBuildType {
#if DEBUG
@@ -10,7 +10,9 @@ import Combine
/// A wrapper of CurrentValueSubject.
/// The purpose of this type is to remove the possibility to send new values on the underlying subject.
struct CurrentValuePublisher<Output, Failure: Error>: Publisher {
///
/// `CurrentValueSubject` is documented as thread-safe but is not formally `Sendable`, hence `@unchecked`.
struct CurrentValuePublisher<Output, Failure: Error>: Publisher, @unchecked Sendable {
private let subject: CurrentValueSubject<Output, Failure>
init(_ subject: CurrentValueSubject<Output, Failure>) {
@@ -15,9 +15,9 @@ enum ExpiringTaskRunnerError: Error {
actor ExpiringTaskRunner<T: Sendable> {
private var continuation: CheckedContinuation<T, Error>?
private var task: () async throws -> T
private var task: @Sendable () async throws -> T
init(_ task: @escaping () async throws -> T) {
init(_ task: @escaping @Sendable () async throws -> T) {
self.task = task
}
+7 -13
View File
@@ -7,6 +7,7 @@
//
import Foundation
import Synchronization
public extension Bundle {
/// The top-level bundle that contains the entire app.
@@ -24,11 +25,11 @@ public extension Bundle {
// MARK: - Localisation
/// Overrides `Bundle.app.preferredLocalizations` for testing translations.
static var overrideLocalizations: [String]?
/// Overrides `Bundle.app.preferredLocalizations` for testing translations since this is
/// only for testing, and is changed at runtime only in tests, it's fine to keep as `nonisolated(unsafe)`
nonisolated(unsafe) static var overrideLocalizations: [String]?
private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.localization_bundle_cache")
private static var cachedBundles = [String: Bundle]()
private static let cachedBundles = Mutex<[String: Bundle]>([:])
/// Get an lproj language bundle from the receiver bundle.
/// - Parameter language: The language to try to load.
@@ -52,17 +53,10 @@ public extension Bundle {
// MARK: - Private
private static func cacheValue(_ value: Bundle?, forKey key: String) {
cacheDispatchQueue.sync {
cachedBundles[key] = value
}
cachedBundles.withLock { $0[key] = value }
}
private static func cachedValue(forKey key: String) -> Bundle? {
var result: Bundle?
cacheDispatchQueue.sync {
result = cachedBundles[key]
}
return result
cachedBundles.withLock { $0[key] }
}
}
@@ -10,6 +10,7 @@ import Compound
import LRUCache
import MatrixRustSDK
import SwiftSoup
import Synchronization
import UIKit
protocol MentionBuilderProtocol {
@@ -42,11 +43,10 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
private let mentionBuilder: MentionBuilderProtocol
private static let attributeMSC4286 = "msc4286-external-payment-details"
private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.attributed_string_builder_v2_cache")
private static var caches: [String: LRUCache<String, AttributedString>] = [:]
private static let caches = Mutex<[String: LRUCache<String, AttributedString>]>([:])
static func invalidateCaches() {
caches.removeAll()
caches.withLock { $0.removeAll() }
}
init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) {
@@ -279,7 +279,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
}
private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) {
cacheDispatchQueue.sync {
caches.withLock { caches in
if caches[cacheKey] == nil {
caches[cacheKey] = LRUCache<String, AttributedString>(countLimit: 1000)
}
@@ -289,12 +289,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
}
private static func cachedValue(forKey key: String, cacheKey: String) -> AttributedString? {
var result: AttributedString?
cacheDispatchQueue.sync {
result = caches[cacheKey]?.value(forKey: key)
}
return result
caches.withLock { $0[cacheKey]?.value(forKey: key) }
}
// swiftlint:disable:next cyclomatic_complexity
+1 -1
View File
@@ -22,7 +22,7 @@ enum Tracing {
/// Set this to temporarily override the directory from which logs will be collected.
/// This basically only affects ``logFiles``, and doesn't inform the SDK to write
/// the logs to a different directory, which should be done before setting this.
static var logsDirectoryOverride: URL?
nonisolated(unsafe) static var logsDirectoryOverride: URL?
static var legacyLogsDirectory: URL {
.appGroupContainerDirectory
}
+3 -3
View File
@@ -13,12 +13,12 @@ import MatrixRustSDK
///
/// To use this you'll need to add a conformance to the required listener
/// protocol with a specialisation for the type it listens for.
final class SDKListener<T> {
private let onUpdateClosure: (T) -> Void
final class SDKListener<T>: Sendable {
private let onUpdateClosure: @Sendable (T) -> Void
/// Creates a new listener.
/// - Parameter onUpdateClosure: A closure that will be called whenever a new value is available.
init(_ onUpdateClosure: @escaping (T) -> Void) {
init(_ onUpdateClosure: @escaping @Sendable (T) -> Void) {
self.onUpdateClosure = onUpdateClosure
}
}
@@ -16,7 +16,7 @@ enum MediaLoaderError: Error {
}
// sourcery: AutoMockable
protocol MediaLoaderProtocol {
protocol MediaLoaderProtocol: Sendable {
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data
@@ -159,7 +159,7 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
}
private func removeReceivedWhileOfflineNotification() {
notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtension.receivedWhileOfflineNotificationID])
notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtensionActor.receivedWhileOfflineNotificationID])
}
private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool {
@@ -12,10 +12,14 @@ import MatrixRustSDK
class TimelineItemProvider: TimelineItemProviderProtocol {
private var cancellables = Set<AnyCancellable>()
private let serialDispatchQueue: DispatchQueue
private var roomTimelineObservationToken: TaskHandle?
/// Bridge from the SDK's synchronous callback into Swift Concurrency. Yielding is safe from any
/// thread; a single long-lived `for await` consumer (set up in `init`) applies the diffs on the
/// main actor in FIFO order, guaranteeing one in-flight update at a time.
private let diffContinuation: AsyncStream<[TimelineDiff]>.Continuation
private let paginationStateSubject = CurrentValueSubject<TimelinePaginationState, Never>(.initial)
var paginationState: TimelinePaginationState {
paginationStateSubject.value
@@ -43,25 +47,34 @@ class TimelineItemProvider: TimelineItemProviderProtocol {
}
deinit {
diffContinuation.finish()
roomTimelineObservationToken?.cancel()
}
init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher<TimelinePaginationState, Never>) {
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.timeline_item_provider", qos: .utility)
itemProxiesSubject = CurrentValueSubject<[TimelineItemProxy], Never>([])
self.kind = kind
let (diffStream, diffContinuation) = AsyncStream<[TimelineDiff]>.makeStream()
self.diffContinuation = diffContinuation
paginationStatePublisher
.sink { [weak self] in
self?.paginationStateSubject.send($0)
}
.store(in: &cancellables)
// `for await` guarantees FIFO ordering and that only one diff is being applied at a time.
// The stream also allows to handle the sendable items in the listener.
Task { [weak self] in
for await diffs in diffStream {
self?.updateItemsWithDiffs(diffs)
}
}
Task {
roomTimelineObservationToken = await timeline.addListener(listener: SDKListener { [weak self] timelineDiffs in
self?.serialDispatchQueue.sync {
self?.updateItemsWithDiffs(timelineDiffs)
}
roomTimelineObservationToken = await timeline.addListener(listener: SDKListener { diffs in
diffContinuation.yield(diffs)
})
}
}
+3 -3
View File
@@ -250,8 +250,8 @@ struct NotificationContentBuilder {
if let fetchedImage {
image = fetchedImage
} else if let data = await getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName,
id: icon.groupInfo?.id ?? senderID) {
} else if let data = await Self.getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName,
id: icon.groupInfo?.id ?? senderID) {
image = INImage(imageData: data)
} else {
image = INImage(named: "")
@@ -309,7 +309,7 @@ struct NotificationContentBuilder {
}
@MainActor
func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
static func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
// The version value is used in case the design of the placeholder is updated to force a replacement
let prefix = "notification_placeholderV9"
+18 -13
View File
@@ -8,6 +8,7 @@
import CallKit
import MatrixRustSDK
import Synchronization
import UserNotifications
class NotificationHandler {
@@ -19,9 +20,6 @@ class NotificationHandler {
private let notificationContentBuilder: NotificationContentBuilder
// periphery:ignore - required for instance retention in the rust codebase
private var roomInfoObservationToken: TaskHandle?
init(userSession: NSEUserSession,
settings: CommonSettingsProtocol,
contentHandler: @escaping (UNNotificationContent) -> Void,
@@ -184,20 +182,27 @@ class NotificationHandler {
// Check to see if a call is still ongoing
if let room = userSession.roomForIdentifier(roomID) { // Try to get call details from the room info
if !room.hasActiveRoomCall() { // If I don't have an active call wait a bit and make sure
// Local `Mutex` holder for the SDK subscription token — keeps it alive across the
// await without needing `[weak self]`, which would force this class to be `Sendable`.
let observationToken = Mutex<TaskHandle?>(nil)
let expiringTask = ExpiringTaskRunner {
await withCheckedContinuation { [weak self] continuation in
self?.roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in
if info.hasRoomCall {
MXLog.info("Received room info update and the room has an active call now.")
continuation.resume()
} else {
MXLog.info("Received a room info update but the room still doesn't have an ongoing call.")
}
})
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
observationToken.withLock { state in
state = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in
if info.hasRoomCall {
MXLog.info("Received room info update and the room has an active call now.")
continuation.resume()
} else {
MXLog.info("Received a room info update but the room still doesn't have an ongoing call.")
}
})
}
}
}
try? await expiringTask.run(timeout: .seconds(5)) // Wait 5 seconds or just use whatever is available
try? await expiringTask.run(timeout: Duration.seconds(5)) // Wait 5 seconds or just use whatever is available
// Release the SDK subscription explicitly once we no longer need updates.
observationToken.withLock { $0 = nil }
guard room.hasActiveRoomCall() else {
MXLog.info("The room no longer has an ongoing call, handling as push notification")
+55 -32
View File
@@ -8,7 +8,8 @@
import Combine
import MatrixRustSDK
import UserNotifications
import Synchronization
@preconcurrency import UserNotifications
// The lifecycle of the NSE looks something like the following:
// 1) App receives notification
@@ -29,31 +30,55 @@ import UserNotifications
// called on the same instance of `NotificationService` as a previous
// notification.
class NotificationServiceExtension: UNNotificationServiceExtension {
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"
private static var targetConfiguration: Target.ConfigurationResult?
/// 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 = AppSettings()
private let appHooks: AppHooks
private var notificationHandler: NotificationHandler?
/// 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)
private var cancellables: Set<AnyCancellable> = []
/// We can make the whole NSE a MainActor after https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md
/// otherwise we wouldn't be able to log the tag in the deinit.
deinit {
ExtensionLogger.logMemory(with: tag)
MXLog.info("\(tag) deinit")
}
override init() {
init() {
appHooks = AppHooks()
appHooks.setUp()
@@ -61,42 +86,40 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// 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.
if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
Self.targetConfiguration == nil {
Self.targetConfiguration = Target.nse.configure(logLevel: settings.logLevel,
traceLogPacks: settings.traceLogPacks,
sentryURL: nil,
rageshakeURL: settings.bugReportRageshakeURL,
appHooks: appHooks)
// 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)
}
}
super.init()
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
Task { await handle(request, withContentHandler: contentHandler) }
}
private func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async {
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 != nil else {
guard Self.targetConfiguration.withLock({ $0 != nil }) else {
// MXLog isn't configured:
// swiftlint:disable:next print_deprecation
print("Device is locked after reboot.")
if Self.hasHandledFirstNotificationSinceBoot {
if await Self.hasHandledFirstNotificationSinceBoot {
return contentHandler(request.content)
} else {
Self.hasHandledFirstNotificationSinceBoot = true
await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
deliverReceivedWhileOfflineNotification(for: request)
return contentHandler(.init())
}
}
guard !shouldDeliverReceivedWhileOfflineNotification() else {
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.")
@@ -167,7 +190,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
}
}
override func serviceExtensionTimeWillExpire() {
nonisolated func serviceExtensionTimeWillExpire() {
notificationHandler?.handleTimeExpiration()
}
@@ -180,13 +203,13 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
///
/// 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() -> Bool {
if Self.hasHandledFirstNotificationSinceBoot {
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
}
Self.hasHandledFirstNotificationSinceBoot = true
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.
@@ -239,7 +262,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// MARK: - Logging
private var tag: String {
private nonisolated var tag: String {
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]"
}
}
+3 -2
View File
@@ -74,6 +74,8 @@ targets:
CURRENT_PROJECT_VERSION: $(CURRENT_PROJECT_VERSION)
DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM)
SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h
SWIFT_VERSION: 6.2
SWIFT_APPROACHABLE_CONCURRENCY: YES
OTHER_SWIFT_FLAGS:
- "-DIS_NSE"
@@ -87,7 +89,6 @@ targets:
- path: ../../Secrets/Secrets.swift
- path: ../../ElementX/Sources/Application/Settings
- path: ../../ElementX/Sources/Application/TargetConfiguration.swift
- path: ../../ElementX/Sources/Generated/Assets.swift
- path: ../../ElementX/Sources/Generated/Strings.swift
- path: ../../ElementX/Sources/Other/Avatars.swift
- path: ../../ElementX/Sources/Other/CurrentValuePublisher.swift
@@ -111,7 +112,7 @@ targets:
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
- path: ../../ElementX/Sources/Other/Logging
- path: ../../ElementX/Sources/Other/MatrixEntityRegex.swift
- path: ../../ElementX/Sources/Other/NetworkMonitor
- path: ../../ElementX/Sources/Other/NetworkMonitor/NetworkMonitorProtocol.swift
- path: ../../ElementX/Sources/Other/Pills/PillUtilities.swift
- path: ../../ElementX/Sources/Other/Pills/PlainMentionBuilder.swift
- path: ../../ElementX/Sources/Other/SDKListener.swift