mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule Enterprise updated: 553ca9a91c...5480892abb
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user