diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 8ce297ce1..cbaa43d66 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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; diff --git a/ElementX/Sources/AppHooks/AppHooks.swift b/ElementX/Sources/AppHooks/AppHooks.swift index c3272bff5..2e370ecba 100644 --- a/ElementX/Sources/AppHooks/AppHooks.swift +++ b/ElementX/Sources/AppHooks/AppHooks.swift @@ -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 = Mutex(DefaultAppSettingsHook()) + var appSettingsHook: AppSettingsHookProtocol { + _appSettingsHook.withLock { $0 } + } + func registerAppSettingsHook(_ hook: AppSettingsHookProtocol) { - appSettingsHook = hook + _appSettingsHook.withLock { $0 = hook } } - private(set) var compoundHook: CompoundHookProtocol = DefaultCompoundHook() + private let _compoundHook: Mutex = Mutex(DefaultCompoundHook()) + var compoundHook: CompoundHookProtocol { + _compoundHook.withLock { $0 } + } + func registerCompoundHook(_ hook: CompoundHookProtocol) { - compoundHook = hook + _compoundHook.withLock { $0 = hook } + } + + private let _bugReportHook: Mutex = 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 = 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 = 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 = 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 = 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 = Mutex(DefaultTracingHook()) + var tracingHook: TracingHookProtocol { + _tracingHook.withLock { $0 } + } + func registerTracingHook(_ hook: TracingHookProtocol) { - tracingHook = hook + _tracingHook.withLock { $0 = hook } + } + + private let _clientBuilderHook: Mutex = 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 = 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() } diff --git a/ElementX/Sources/AppHooks/Hooks/ClientBuilderHook.swift b/ElementX/Sources/AppHooks/Hooks/ClientBuilderHook.swift index f936f0ecd..7117fc17c 100644 --- a/ElementX/Sources/AppHooks/Hooks/ClientBuilderHook.swift +++ b/ElementX/Sources/AppHooks/Hooks/ClientBuilderHook.swift @@ -8,7 +8,7 @@ import MatrixRustSDK -protocol ClientBuilderHookProtocol { +protocol ClientBuilderHookProtocol: Sendable { func configure(_ builder: ClientBuilder) -> ClientBuilder } diff --git a/ElementX/Sources/AppHooks/Hooks/RemoteSettingsHook.swift b/ElementX/Sources/AppHooks/Hooks/RemoteSettingsHook.swift index c020f31a2..03f5415a7 100644 --- a/ElementX/Sources/AppHooks/Hooks/RemoteSettingsHook.swift +++ b/ElementX/Sources/AppHooks/Hooks/RemoteSettingsHook.swift @@ -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 func updateCache(using client: ClientProtocol) async diff --git a/ElementX/Sources/AppHooks/Hooks/TracingHook.swift b/ElementX/Sources/AppHooks/Hooks/TracingHook.swift index 01b5345f9..f8279708f 100644 --- a/ElementX/Sources/AppHooks/Hooks/TracingHook.swift +++ b/ElementX/Sources/AppHooks/Hooks/TracingHook.swift @@ -8,7 +8,7 @@ import MatrixRustSDK -protocol TracingHookProtocol { +protocol TracingHookProtocol: Sendable { func update(_ configuration: TracingConfiguration, with rageshakeURL: RemotePreference) } diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 19192c69d..9f2575b04 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -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 diff --git a/ElementX/Sources/Other/CurrentValuePublisher.swift b/ElementX/Sources/Other/CurrentValuePublisher.swift index e2b6dfcd4..a210be078 100644 --- a/ElementX/Sources/Other/CurrentValuePublisher.swift +++ b/ElementX/Sources/Other/CurrentValuePublisher.swift @@ -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: Publisher { +/// +/// `CurrentValueSubject` is documented as thread-safe but is not formally `Sendable`, hence `@unchecked`. +struct CurrentValuePublisher: Publisher, @unchecked Sendable { private let subject: CurrentValueSubject init(_ subject: CurrentValueSubject) { diff --git a/ElementX/Sources/Other/ExpiringTaskRunner.swift b/ElementX/Sources/Other/ExpiringTaskRunner.swift index 40057af79..ecb470571 100644 --- a/ElementX/Sources/Other/ExpiringTaskRunner.swift +++ b/ElementX/Sources/Other/ExpiringTaskRunner.swift @@ -15,9 +15,9 @@ enum ExpiringTaskRunnerError: Error { actor ExpiringTaskRunner { private var continuation: CheckedContinuation? - private var task: () async throws -> T - - init(_ task: @escaping () async throws -> T) { + private var task: @Sendable () async throws -> T + + init(_ task: @escaping @Sendable () async throws -> T) { self.task = task } diff --git a/ElementX/Sources/Other/Extensions/Bundle.swift b/ElementX/Sources/Other/Extensions/Bundle.swift index 481431163..40c055e9f 100644 --- a/ElementX/Sources/Other/Extensions/Bundle.swift +++ b/ElementX/Sources/Other/Extensions/Bundle.swift @@ -7,6 +7,7 @@ // import Foundation +import Synchronization public extension Bundle { /// The top-level bundle that contains the entire app. @@ -24,12 +25,12 @@ public extension Bundle { // MARK: - Localisation - /// Overrides `Bundle.app.preferredLocalizations` for testing translations. - static var overrideLocalizations: [String]? - - private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.localization_bundle_cache") - private static var cachedBundles = [String: Bundle]() - + /// 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 cachedBundles = Mutex<[String: Bundle]>([:]) + /// Get an lproj language bundle from the receiver bundle. /// - Parameter language: The language to try to load. /// - Returns: The lproj bundle if found otherwise nil. @@ -37,32 +38,25 @@ public extension Bundle { if let bundle = cachedValue(forKey: language) { return bundle } - + guard let lprojURL = Bundle.app.url(forResource: language, withExtension: "lproj") else { return nil } - + let bundle = Bundle(url: lprojURL) - + cacheValue(bundle, forKey: language) - + return 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] } } } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 30870e139..fbc8bcd76 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -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] = [:] + private static let caches = Mutex<[String: LRUCache]>([:]) static func invalidateCaches() { - caches.removeAll() + caches.withLock { $0.removeAll() } } init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) { @@ -279,22 +279,17 @@ 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(countLimit: 1000) } - + caches[cacheKey]?.setValue(value, forKey: key) } } - + 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 diff --git a/ElementX/Sources/Other/Logging/Tracing.swift b/ElementX/Sources/Other/Logging/Tracing.swift index 93d6c0f15..ffd6796c1 100644 --- a/ElementX/Sources/Other/Logging/Tracing.swift +++ b/ElementX/Sources/Other/Logging/Tracing.swift @@ -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 } diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index e2d88c612..c8284af74 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -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 { - private let onUpdateClosure: (T) -> Void - +final class SDKListener: 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 } } diff --git a/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift b/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift index 3d372f8b0..2763228b0 100644 --- a/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift +++ b/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift @@ -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 diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 03e2dc883..bb8626408 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -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 { diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItemProvider.swift index 9bbfaca73..8205cebc1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProvider.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProvider.swift @@ -12,10 +12,14 @@ import MatrixRustSDK class TimelineItemProvider: TimelineItemProviderProtocol { private var cancellables = Set() - 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(.initial) var paginationState: TimelinePaginationState { paginationStateSubject.value @@ -33,35 +37,44 @@ class TimelineItemProvider: TimelineItemProviderProtocol { .combineLatest(paginationStateSubject) .eraseToAnyPublisher() } - + let kind: TimelineKind - + private let membershipChangeSubject = PassthroughSubject() var membershipChangePublisher: AnyPublisher { membershipChangeSubject .eraseToAnyPublisher() } - + deinit { + diffContinuation.finish() roomTimelineObservationToken?.cancel() } init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher) { - 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) }) } } diff --git a/Enterprise b/Enterprise index 553ca9a91..5480892ab 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit 553ca9a91cf11603ee85fbf86e83d7c79250b7dd +Subproject commit 5480892abbb15f72eb3dd3e994d447d601aecd77 diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index b139172ba..43e089966 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -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" diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index 729dfdf29..ebaaa92ac 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -8,6 +8,7 @@ import CallKit import MatrixRustSDK +import Synchronization import UserNotifications class NotificationHandler { @@ -18,10 +19,7 @@ class NotificationHandler { private let tag: String 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,21 +182,28 @@ 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(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) 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") return .shouldDisplay diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index bc65eaffa..d8f3b558b 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -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,74 +30,96 @@ 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(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 = [] - - /// 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() - + // 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. - 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. @@ -238,8 +261,8 @@ 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)]" } } diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 82bd0574a..76bda76c0 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -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