diff --git a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift index 3ec4cb15c42..e3d39688c04 100644 --- a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift +++ b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift @@ -808,7 +808,7 @@ public final class ClientSessionComponent { public lazy var workAgent: WorkAgent = .init(scheduler: PriorityOrderWorkItemScheduler()) - public lazy var conversationUpdatesGenerator: ConversationUpdatesGeneratorProtocol = ConversationUpdatesGenerator( + public lazy var conversationUpdatesGenerator: IncrementalGeneratorProtocol = ConversationUpdatesGenerator( repository: conversationRepository, context: syncContext, onConversationUpdated: { [weak self] workItem in @@ -817,4 +817,29 @@ public final class ClientSessionComponent { } ) + public lazy var commitPendingProposalsGenerator: LiveGeneratorProtocol = CommitPendingProposalsGenerator( + repository: conversationRepository, + mlsService: mlsService, + context: syncContext, + isMLSGroupBroken: { [weak self] groupID in + self?.isMLSGroupBroken(groupID: groupID) == true + }, + onCommitPendingProposals: { [weak self] workItem in + + self?.workAgent.submitItem(workItem) + } + ) + + public lazy var generatorsDirectory = GeneratorsDirectory( + generators: [ + conversationUpdatesGenerator, + commitPendingProposalsGenerator + ], + syncStatePublisher: syncStateSubject.eraseToAnyPublisher() + ) + + private func isMLSGroupBroken(groupID: MLSGroupID) -> Bool { + let brokenGroupIds = journal[.brokenMLSGroupIDs] + return brokenGroupIds.contains(groupID.description) + } } diff --git a/WireDomain/Sources/WireDomain/Event Decryption/UpdateEventDecryptor.swift b/WireDomain/Sources/WireDomain/Event Decryption/UpdateEventDecryptor.swift index 3110f1fef7f..51a9944a8f0 100644 --- a/WireDomain/Sources/WireDomain/Event Decryption/UpdateEventDecryptor.swift +++ b/WireDomain/Sources/WireDomain/Event Decryption/UpdateEventDecryptor.swift @@ -80,7 +80,6 @@ struct UpdateEventDecryptor: UpdateEventDecryptorProtocol { var decryptedEvents = [UpdateEvent]() var brokenMLSGroupIDs = Set() - var shouldCommitPendingProposals = false for event in eventEnvelope.events { logAttributes[.messageType] = event.name @@ -120,7 +119,6 @@ struct UpdateEventDecryptor: UpdateEventDecryptorProtocol { "decrypting MLS add message event...", attributes: logAttributes ) - shouldCommitPendingProposals = true do { let decryptedEventData = try await mlsMessageDecryptor.decryptedMessageAddEventData( @@ -170,23 +168,9 @@ struct UpdateEventDecryptor: UpdateEventDecryptorProtocol { } } - if shouldCommitPendingProposals { - Task.detached { - // we don't need to wait for this, as it can take a while to finish - // it should not block decryption - await commitPendingProposalsIfNeeded() - } - } - return EventDecryptorResult(events: decryptedEvents, brokenMLSGroupIDs: brokenMLSGroupIDs) } - private func commitPendingProposalsIfNeeded() async { - // MLSService will be nil when called from push notification service. - // As we don't need to commit pending proposals in that case. - await mlsService?.commitPendingProposalsIfNeeded() - } - private func appendFailedToDecryptProteusMessage( eventData: ConversationProteusMessageAddEvent, error: ProteusError diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift index ea741ea600e..9a291c2912d 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/LocalStore/ConversationLocalStore.swift @@ -1302,6 +1302,15 @@ public final class ConversationLocalStore: ConversationLocalStoreProtocol { } } + public func execute( + identifier: MLSGroupID, + block: @escaping @Sendable (ZMConversation?, NSManagedObjectContext) -> Void + ) async { + await context.perform { [context] in + let conversation = ZMConversation.fetch(with: identifier, in: context) + block(conversation, context) + } + } } // MARK: - Private helpers diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift index 84dff1cc2df..3abd1de01ae 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationLocalStoreProtocol.swift @@ -469,4 +469,8 @@ public protocol ConversationLocalStoreProtocol { func fetchServerTimeDelta() async -> TimeInterval + func execute( + identifier: MLSGroupID, + block: @escaping @Sendable (ZMConversation?, NSManagedObjectContext) -> Void + ) async } diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationRepositoryProtocol.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationRepositoryProtocol.swift index b36cf37ba2c..61985a3420c 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationRepositoryProtocol.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/Protocols/ConversationRepositoryProtocol.swift @@ -192,4 +192,10 @@ public protocol ConversationRepositoryProtocol: Sendable { conversationID: String ) async throws -> String? + /// Checks if selfUser is still in a given conversation + /// - Parameter groupID: mlsGroupID of the conversation + /// - Returns: true if selfUser belongs to the conversation, false otherwise + func isSelfAnActiveMember( + in groupID: WireDataModel.MLSGroupID + ) async -> Bool } diff --git a/WireDomain/Sources/WireDomain/Repositories/Conversations/Repository/ConversationRepository.swift b/WireDomain/Sources/WireDomain/Repositories/Conversations/Repository/ConversationRepository.swift index c07a0eac74d..9a668502fcd 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Conversations/Repository/ConversationRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Conversations/Repository/ConversationRepository.swift @@ -377,6 +377,16 @@ public final class ConversationRepository: ConversationRepositoryProtocol { await deleteMembership(for: removedUserIDs, time: date) } + public func isSelfAnActiveMember( + in groupID: MLSGroupID + ) async -> Bool { + nonisolated(unsafe) var isSelfAnActiveMember = false + await conversationsLocalStore.execute(identifier: groupID) { conversation, _ in + isSelfAnActiveMember = conversation?.isSelfAnActiveMember ?? false + } + return isSelfAnActiveMember + } + // MARK: - Private private func addSystemMessage( diff --git a/WireDomain/Sources/WireDomain/Synchronization/CommitPendingProsalsGenerator.swift b/WireDomain/Sources/WireDomain/Synchronization/CommitPendingProsalsGenerator.swift new file mode 100644 index 00000000000..aaf241cc1a8 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Synchronization/CommitPendingProsalsGenerator.swift @@ -0,0 +1,189 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// +import Foundation +import WireDataModel +import WireLogging + +public final class CommitPendingProposalsGenerator: NSObject, LiveGeneratorProtocol { + + private let context: NSManagedObjectContext + private var fetchedResultsController: NSFetchedResultsController? + private let repository: ConversationRepositoryProtocol + private let mlsService: MLSServiceInterface + private let isMLSGroupBroken: (MLSGroupID) -> Bool + private var onCommitPendingProposals: (CommitPendingProposalItem) -> Void + + private var scheduledTasks: [QualifiedID: Task] = [:] + + init( + repository: ConversationRepositoryProtocol, + mlsService: MLSServiceInterface, + context: NSManagedObjectContext, + isMLSGroupBroken: @escaping (MLSGroupID) -> Bool, + onCommitPendingProposals: @escaping (CommitPendingProposalItem) -> Void + ) { + self.context = context + self.onCommitPendingProposals = onCommitPendingProposals + self.repository = repository + self.mlsService = mlsService + self.isMLSGroupBroken = isMLSGroupBroken + super.init() + } + + public func start() async { + if fetchedResultsController == nil { + fetchedResultsController = createFetchedResultsController() + fetchedResultsController?.delegate = self + } + + await context.perform { + do { + try self.fetchedResultsController?.performFetch() + } catch { + WireLogger.conversation.error("error fetching conversations: \(String(describing: error))") + } + + let conversations = self.fetchedResultsController?.fetchedObjects ?? [] + for conversation in conversations { + self.scheduleCommitIfNeeded(for: conversation) + } + } + } + + public func stop() { + fetchedResultsController = nil + + // Cancel all scheduled commits + for (_, task) in scheduledTasks { + task.cancel() + } + scheduledTasks.removeAll() + } + + private func createFetchedResultsController() -> NSFetchedResultsController { + let request = NSFetchRequest(entityName: ZMConversation.entityName()) + request.predicate = ZMConversation.commitPendingProposalDatePredicate() + request.sortDescriptors = [ZMConversation.sortCommitPendingProsalsByDateAscending()] + return NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + } + + private func scheduleCommitIfNeeded(for conversation: ZMConversation) { + guard + let conversationID = conversation.qualifiedID, + let timestamp = conversation.commitPendingProposalDate, + let mlsGroupID = conversation.mlsGroupID, + conversation.isSelfAnActiveMember, + !isMLSGroupBroken(mlsGroupID) + else { + // If the conversation no longer qualifies, cancel any existing schedule. + if let id = conversation.qualifiedID { + scheduledTasks[id]?.cancel() + scheduledTasks[id] = nil + } + return + } + + // Reschedule (cancel previous if any) + scheduledTasks[conversationID]?.cancel() + + // we create a task that will generate a workItem in time because we don't want to block the WorkAgent from + // executing other workItems + let task = Task { [repository, mlsService, onCommitPendingProposals] in + + let delay = timestamp.timeIntervalSinceNow + if delay > 0 { + do { try await Task.sleep(for: .seconds(delay)) } catch { return } // cancelled + } + + // Re-check membership right before enqueuing the actual work item + let stillMember = await repository.isSelfAnActiveMember(in: mlsGroupID) + guard stillMember else { return } + + // Enqueue parent group item + onCommitPendingProposals( + CommitPendingProposalItem( + repository: repository, + conversationID: conversationID, + groupID: mlsGroupID, + mlsService: mlsService + ) + ) + + // Enqueue subconversation item if any + if let subgroupID = await mlsService.conferenceSubconversation(parentGroupID: mlsGroupID) { + onCommitPendingProposals( + CommitPendingProposalItem( + repository: repository, + conversationID: conversationID, + groupID: subgroupID, + mlsService: mlsService + ) + ) + } + } + + scheduledTasks[conversationID] = task + + WireLogger.workAgent.debug( + "scheduled commit pending proposal work-item", + attributes: [.mlsGroupID: mlsGroupID.safeForLoggingDescription] + ) + } +} + +// MARK: - NSFetchedResultsControllerDelegate + +extension CommitPendingProposalsGenerator: NSFetchedResultsControllerDelegate { + + public func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + guard let conversation = anObject as? ZMConversation else { + fatal("unexpected object, expected ZMConversation") + } + + switch type { + case .insert, .update: + // run on the context queue to safely read properties + Task { [context] in + await context.perform { + self.scheduleCommitIfNeeded(for: conversation) + } + } + + case .move, .delete: + // Best effort cancel if we can identify it + if let id = conversation.qualifiedID { + scheduledTasks[id]?.cancel() + scheduledTasks[id] = nil + } + + @unknown default: + break + } + } +} diff --git a/WireDomain/Sources/WireDomain/Synchronization/ConversationUpdatesGenerator.swift b/WireDomain/Sources/WireDomain/Synchronization/ConversationUpdatesGenerator.swift index f21e1aead4d..ed433ec3515 100644 --- a/WireDomain/Sources/WireDomain/Synchronization/ConversationUpdatesGenerator.swift +++ b/WireDomain/Sources/WireDomain/Synchronization/ConversationUpdatesGenerator.swift @@ -19,13 +19,7 @@ import Foundation import WireDataModel import WireLogging -/// sourcery: AutoMockable -public protocol ConversationUpdatesGeneratorProtocol { - func start() async - func stop() -} - -public final class ConversationUpdatesGenerator: NSObject, ConversationUpdatesGeneratorProtocol { +public final class ConversationUpdatesGenerator: NSObject, IncrementalGeneratorProtocol { private let context: NSManagedObjectContext private var fetchedResultsController: NSFetchedResultsController? @@ -46,7 +40,7 @@ public final class ConversationUpdatesGenerator: NSObject, ConversationUpdatesGe /// Starts monitoring and triggers pulls for any needingToBeUpdatedFromBackend conversations. public func start() async { if fetchedResultsController == nil { - fetchedResultsController = createFetchRequestController() + fetchedResultsController = createFetchedResultsController() fetchedResultsController?.delegate = self } await context.perform { @@ -73,7 +67,7 @@ public final class ConversationUpdatesGenerator: NSObject, ConversationUpdatesGe fetchedResultsController = nil } - private func createFetchRequestController() -> NSFetchedResultsController { + private func createFetchedResultsController() -> NSFetchedResultsController { let request = NSFetchRequest(entityName: ZMConversation.entityName()) request.predicate = NSPredicate.all(of: [ ZMConversation.predicateForNeedingToBeUpdatedFromBackend(), @@ -87,7 +81,6 @@ public final class ConversationUpdatesGenerator: NSObject, ConversationUpdatesGe sectionNameKeyPath: nil, cacheName: nil ) - } } diff --git a/WireDomain/Sources/WireDomain/Synchronization/GeneratorsDirectory.swift b/WireDomain/Sources/WireDomain/Synchronization/GeneratorsDirectory.swift new file mode 100644 index 00000000000..4c28e50d6d8 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Synchronization/GeneratorsDirectory.swift @@ -0,0 +1,90 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// +import Combine + +// sourcery: AutoMockable +public protocol GeneratorProtocol { + func start() async + func stop() +} + +// sourcery: AutoMockable +/// Starts generating items during pulling pending events from backend +public protocol IncrementalGeneratorProtocol: GeneratorProtocol {} + +// sourcery: AutoMockable +/// Starts generating items once the app is up to date (livesyncing)/ +public protocol LiveGeneratorProtocol: GeneratorProtocol {} + +/// Object that holds on all generators of WorkItem for WorkAgent + +public final class GeneratorsDirectory { + + private let generators: [any GeneratorProtocol] + private let syncStatePublisher: AnyPublisher + private var cancellables: Set = [] + + public init(generators: [any GeneratorProtocol], syncStatePublisher: AnyPublisher) { + self.generators = generators + self.syncStatePublisher = syncStatePublisher + } + + public func observeSyncState() { + syncStatePublisher.sink { [weak self] state in + switch state { + case .idle, .initialSyncing: + // make sure no generators are working here + self?.stopGenerators() + + case .incrementalSyncing(.createPushChannel): + self?.startIncrementalGenerators() + + case .incrementalSyncing: + break // sync is ongoing, do nothing + + case .liveSyncing(.ongoing): + self?.startLiveGenerators() + + case .suspended, .liveSyncing(.finished): + self?.stopGenerators() + } + }.store(in: &cancellables) + } + + private func startIncrementalGenerators() { + for generator in generators where generator is IncrementalGeneratorProtocol { + Task { + await generator.start() + } + } + } + + private func startLiveGenerators() { + for generator in generators where generator is LiveGeneratorProtocol { + Task { + await generator.start() + } + } + } + + private func stopGenerators() { + for generator in generators { + generator.stop() + } + } +} diff --git a/WireDomain/Sources/WireDomain/Synchronization/IncrementalSyncV2.swift b/WireDomain/Sources/WireDomain/Synchronization/IncrementalSyncV2.swift index 4be85ae494d..4e6b6fafaab 100644 --- a/WireDomain/Sources/WireDomain/Synchronization/IncrementalSyncV2.swift +++ b/WireDomain/Sources/WireDomain/Synchronization/IncrementalSyncV2.swift @@ -116,6 +116,7 @@ public struct IncrementalSyncV2: LiveSyncProtocol { let pushChannel: PushChannelV2Protocol do { + syncStateSubject.send(.incrementalSyncing(.createPushChannel)) pushChannel = try await pushChannelAPI.createPushChannel(clientID: selfClientID, marker: syncMarker) } catch { await pushChannelState.markAsClosed() diff --git a/WireDomain/Sources/WireDomain/WorkAgent/WorkItem/CommitPendingProposalItem.swift b/WireDomain/Sources/WireDomain/WorkAgent/WorkItem/CommitPendingProposalItem.swift new file mode 100644 index 00000000000..522607a11a8 --- /dev/null +++ b/WireDomain/Sources/WireDomain/WorkAgent/WorkItem/CommitPendingProposalItem.swift @@ -0,0 +1,66 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +@preconcurrency import WireDataModel +import WireLogging +import WireNetwork + +struct CommitPendingProposalItem: WorkItem, CustomStringConvertible { + private let repository: ConversationRepositoryProtocol + private let mlsService: MLSServiceInterface + + var description: String { + "CommitPendingProposalItem: \(id), mlsGroupID: \(groupID), conversationID: \(conversationID)" + } + + let id = UUID() + var priority: WorkItemPriority { + .medium + } + + let conversationID: WireDataModel.QualifiedID + let groupID: WireDataModel.MLSGroupID + let logger = WireLogger.mls + + public init( + repository: ConversationRepositoryProtocol, + conversationID: WireDataModel.QualifiedID, + groupID: WireDataModel.MLSGroupID, + mlsService: MLSServiceInterface + ) { + self.repository = repository + self.conversationID = conversationID + self.groupID = groupID + self.mlsService = mlsService + } + + func start() async throws { + let logAttributes: LogAttributes = [.mlsGroupID: groupID.safeForLoggingDescription] + LogAttributes.safePublic + + let isSelfAnActiveMember = await repository.isSelfAnActiveMember(in: groupID) + + guard isSelfAnActiveMember else { + logger.info("cancelling commit as the user is no longer a member", attributes: logAttributes) + return + } + + logger.info("committing pending proposals now...", attributes: logAttributes) + try await mlsService.commitPendingProposals(in: groupID) + } +} diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index 794e2684706..75a2de23da0 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -1540,6 +1540,21 @@ public class MockConversationLocalStoreProtocol: ConversationLocalStoreProtocol } } + // MARK: - execute + + public var executeIdentifierBlock_Invocations: [(identifier: MLSGroupID, block: (ZMConversation?, NSManagedObjectContext) -> Void)] = [] + public var executeIdentifierBlock_MockMethod: ((MLSGroupID, @Sendable @escaping (ZMConversation?, NSManagedObjectContext) -> Void) async -> Void)? + + public func execute(identifier: MLSGroupID, block: @Sendable @escaping (ZMConversation?, NSManagedObjectContext) -> Void) async { + executeIdentifierBlock_Invocations.append((identifier: identifier, block: block)) + + guard let mock = executeIdentifierBlock_MockMethod else { + fatalError("no mock for `executeIdentifierBlock`") + } + + await mock(identifier, block) + } + } class MockConversationLocationMessageNotificationBuilderProtocol: ConversationLocationMessageNotificationBuilderProtocol { @@ -1953,6 +1968,24 @@ public class MockConversationRepositoryProtocol: ConversationRepositoryProtocol, } } + // MARK: - isSelfAnActiveMember + + public var isSelfAnActiveMemberIn_Invocations: [WireDataModel.MLSGroupID] = [] + public var isSelfAnActiveMemberIn_MockMethod: ((WireDataModel.MLSGroupID) async -> Bool)? + public var isSelfAnActiveMemberIn_MockValue: Bool? + + public func isSelfAnActiveMember(in groupID: WireDataModel.MLSGroupID) async -> Bool { + isSelfAnActiveMemberIn_Invocations.append(groupID) + + if let mock = isSelfAnActiveMemberIn_MockMethod { + return await mock(groupID) + } else if let mock = isSelfAnActiveMemberIn_MockValue { + return mock + } else { + fatalError("no mock for `isSelfAnActiveMemberIn`") + } + } + } class MockConversationTextMessageNotificationBuilderProtocol: ConversationTextMessageNotificationBuilderProtocol { @@ -1981,45 +2014,6 @@ class MockConversationTextMessageNotificationBuilderProtocol: ConversationTextMe } -public class MockConversationUpdatesGeneratorProtocol: ConversationUpdatesGeneratorProtocol { - - // MARK: - Life cycle - - public init() {} - - - // MARK: - start - - public var start_Invocations: [Void] = [] - public var start_MockMethod: (() async -> Void)? - - public func start() async { - start_Invocations.append(()) - - guard let mock = start_MockMethod else { - fatalError("no mock for `start`") - } - - await mock() - } - - // MARK: - stop - - public var stop_Invocations: [Void] = [] - public var stop_MockMethod: (() -> Void)? - - public func stop() { - stop_Invocations.append(()) - - guard let mock = stop_MockMethod else { - fatalError("no mock for `stop`") - } - - mock() - } - -} - class MockConversationVideoMessageNotificationBuilderProtocol: ConversationVideoMessageNotificationBuilderProtocol { // MARK: - Life cycle @@ -2342,6 +2336,84 @@ class MockGenerateNotificationUseCaseProtocol: GenerateNotificationUseCaseProtoc } +public class MockGeneratorProtocol: GeneratorProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - start + + public var start_Invocations: [Void] = [] + public var start_MockMethod: (() async -> Void)? + + public func start() async { + start_Invocations.append(()) + + guard let mock = start_MockMethod else { + fatalError("no mock for `start`") + } + + await mock() + } + + // MARK: - stop + + public var stop_Invocations: [Void] = [] + public var stop_MockMethod: (() -> Void)? + + public func stop() { + stop_Invocations.append(()) + + guard let mock = stop_MockMethod else { + fatalError("no mock for `stop`") + } + + mock() + } + +} + +public class MockIncrementalGeneratorProtocol: IncrementalGeneratorProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - start + + public var start_Invocations: [Void] = [] + public var start_MockMethod: (() async -> Void)? + + public func start() async { + start_Invocations.append(()) + + guard let mock = start_MockMethod else { + fatalError("no mock for `start`") + } + + await mock() + } + + // MARK: - stop + + public var stop_Invocations: [Void] = [] + public var stop_MockMethod: (() -> Void)? + + public func stop() { + stop_Invocations.append(()) + + guard let mock = stop_MockMethod else { + fatalError("no mock for `stop`") + } + + mock() + } + +} + public class MockIncrementalSyncProtocol: IncrementalSyncProtocol { // MARK: - Life cycle @@ -2403,6 +2475,45 @@ public class MockInitialSyncProtocol: InitialSyncProtocol { } +public class MockLiveGeneratorProtocol: LiveGeneratorProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - start + + public var start_Invocations: [Void] = [] + public var start_MockMethod: (() async -> Void)? + + public func start() async { + start_Invocations.append(()) + + guard let mock = start_MockMethod else { + fatalError("no mock for `start`") + } + + await mock() + } + + // MARK: - stop + + public var stop_Invocations: [Void] = [] + public var stop_MockMethod: (() -> Void)? + + public func stop() { + stop_Invocations.append(()) + + guard let mock = stop_MockMethod else { + fatalError("no mock for `stop`") + } + + mock() + } + +} + public class MockLiveSyncDelegate: LiveSyncDelegate { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/Event Decryption/UpdateEventDecryptorTests.swift b/WireDomain/Tests/WireDomainTests/Event Decryption/UpdateEventDecryptorTests.swift index 9b6084c0b94..f43173b1398 100644 --- a/WireDomain/Tests/WireDomainTests/Event Decryption/UpdateEventDecryptorTests.swift +++ b/WireDomain/Tests/WireDomainTests/Event Decryption/UpdateEventDecryptorTests.swift @@ -55,7 +55,6 @@ final class UpdateEventDecryptorTests: XCTestCase { mlsService: mlsService, messageLocalStore: messageLocalStore ) - mlsService.commitPendingProposalsIfNeeded_MockMethod = {} } override func tearDown() async throws { @@ -200,11 +199,6 @@ final class UpdateEventDecryptorTests: XCTestCase { .user(.pushRemove) ] ) - - // Hack to allow `commitPendingProposalsIfNeeded` to complete :( - await Task(priority: .low) { await Task.yield() }.value - - XCTAssertEqual(mlsService.commitPendingProposalsIfNeeded_Invocations.count, 1) } func testWhenWrongEpochErrorIsThrown() async throws { diff --git a/WireDomain/Tests/WireDomainTests/Synchronization/CommitPendingProposalsGeneratorTests.swift b/WireDomain/Tests/WireDomainTests/Synchronization/CommitPendingProposalsGeneratorTests.swift new file mode 100644 index 00000000000..ac6b5b5dda1 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/Synchronization/CommitPendingProposalsGeneratorTests.swift @@ -0,0 +1,163 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Testing +import WireDataModel +import WireDataModelSupport +import WireDomainSupport +@testable import WireDomain + +class CommitPendingProposalsGeneratorTests { + + var sut: CommitPendingProposalsGenerator! + let repository: MockConversationRepositoryProtocol + let modelHelper = ModelHelper() + let coreDataStackHelper = CoreDataStackHelper() + let coreDataStack: CoreDataStack + var commitPendingProposalItemClosure: ((CommitPendingProposalItem) -> Void)? + var mockMLSService: MockMLSServiceInterface! + var isMLSGroupBroken: (MLSGroupID) -> Bool = { _ in false } + + init() async throws { + self.repository = MockConversationRepositoryProtocol() + repository.isSelfAnActiveMemberIn_MockValue = true + + self.mockMLSService = MockMLSServiceInterface() + mockMLSService.conferenceSubconversationParentGroupID_MockMethod = { _ in nil } + + self.coreDataStack = try await coreDataStackHelper.createStack() + + self.sut = CommitPendingProposalsGenerator( + repository: repository, + mlsService: mockMLSService, + context: coreDataStack.syncContext, + isMLSGroupBroken: { + self.isMLSGroupBroken($0) + }, + onCommitPendingProposals: { item in + self.commitPendingProposalItemClosure?(item) + } + ) + } + + @Test( + "It generates an item when a conversation with commitPendingProposalDate set is found", + arguments: [Date(), Date().addingTimeInterval(0.5)] + ) + func startGeneratesItem(date: Date) async throws { + // GIVEN + let conversationID = QualifiedID.random() + await createPendingMLSConversation( + id: conversationID, + proposalDate: date + ) + + var items = [CommitPendingProposalItem]() + commitPendingProposalItemClosure = { item in + items.append(item) + } + // WHEN + await sut.start() + + // THEN + #expect(items.count == 1) + #expect(items.first?.conversationID == conversationID) + + // WHEN + let newConversationID = QualifiedID.random() + try await confirmation("generator delivers an update for a new conversation") { confirm in + self.commitPendingProposalItemClosure = { item in + items.append(item) + confirm() + } + + await self.createPendingMLSConversation( + id: newConversationID, + proposalDate: date + ) + + try await Task.sleep(for: .seconds(0.5)) + } + + // THEN + #expect(items.count == 2) + #expect(items.last?.conversationID == newConversationID) + } + + @Test("It does not generate an item on conversation insertion") + func startDoesNotGenerateItem() async throws { + // GIVEN + var items = [CommitPendingProposalItem]() + commitPendingProposalItemClosure = { item in + items.append(item) + } + let conversationID = QualifiedID.random() + _ = await coreDataStack.syncContext.perform { [modelHelper, context = coreDataStack.syncContext] in + modelHelper.createMLSConversation( + id: conversationID.uuid, + domain: conversationID.domain, + in: context + ) + } + + // WHEN + await sut.start() + + // THEN + #expect(items.isEmpty) + } + + @Test("It does not generate an item when mls group is broken") + func startDoesNotGenerateItemWhenBrokenMLSGroup() async throws { + // GIVEN + var items = [CommitPendingProposalItem]() + commitPendingProposalItemClosure = { item in + items.append(item) + } + isMLSGroupBroken = { _ in + + true + } + + let conversationID = QualifiedID.random() + await createPendingMLSConversation( + id: conversationID, + proposalDate: Date() + ) + + // WHEN + await sut.start() + + // THEN + #expect(items.isEmpty) + } + + private func createPendingMLSConversation(id: QualifiedID, proposalDate: Date) async { + _ = await coreDataStack.syncContext.perform { [context = coreDataStack.syncContext, modelHelper] in + let selfUser = ZMUser.selfUser(in: context) + let conversation = modelHelper.createMLSConversation( + id: id.uuid, + domain: id.domain, + mlsGroupID: .random(), + with: [selfUser], + in: context + ) + conversation.commitPendingProposalDate = Date() + } + } +} diff --git a/WireDomain/Tests/WireDomainTests/Synchronization/GeneratorsDirectoryTests.swift b/WireDomain/Tests/WireDomainTests/Synchronization/GeneratorsDirectoryTests.swift new file mode 100644 index 00000000000..485330d4e85 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/Synchronization/GeneratorsDirectoryTests.swift @@ -0,0 +1,150 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Combine +import Testing + +import WireDomainSupport +@testable import WireDomain + +@Suite("GeneratorsDirectory") +struct GeneratorsDirectoryTests { + + // MARK: - Helpers + + /// Awaits the first time `block` is executed (used to reliably observe async `.start()` calls triggered from a + /// `Task { }`). + private func waitForCall(_ installHandler: (@escaping () -> Void) -> Void) async { + await withCheckedContinuation { (c: CheckedContinuation) in + installHandler { c.resume() } + } + } + + let subject = PassthroughSubject() + let base = MockGeneratorProtocol() + let live = MockLiveGeneratorProtocol() + let incremental = MockIncrementalGeneratorProtocol() + + init() { + base.start_MockMethod = {} + base.stop_MockMethod = {} + live.start_MockMethod = {} + live.stop_MockMethod = {} + incremental.start_MockMethod = {} + incremental.stop_MockMethod = {} + } + + // MARK: - Tests + + @Test("idle / initialSyncing stops all generators", arguments: [ + SyncState.idle, .initialSyncing(.pullLastEventID), + .initialSyncing(.pullResources), + .initialSyncing(.pushSupportedProtocols), + .initialSyncing(.resolveOneOnOneConversations) + ]) + func idleStopsAllGenerators(state: SyncState) async { + // GIVEN + let sut = GeneratorsDirectory( + generators: [base, live, incremental], + syncStatePublisher: subject.eraseToAnyPublisher() + ) + sut.observeSyncState() + + // WHEN + subject.send(state) + + // THEN + #expect(base.stop_Invocations.count == 1) + #expect(live.stop_Invocations.count == 1) + #expect(incremental.stop_Invocations.count == 1) + } + + @Test("incrementalSyncing(.createPushChannel) starts incremental generators (and not live ones)") + func createPushChannelStartsIncrementalGenerators() async { + // GIVEN + let sut = GeneratorsDirectory( + generators: [live, incremental], + syncStatePublisher: subject.eraseToAnyPublisher() + ) + + sut.observeSyncState() + + async let incrementalStarted: Void = waitForCall { resume in + incremental.start_MockMethod = { resume() } + } + + // Ensure live does NOT start on this state + live.start_MockMethod = { + Issue.record("Live generator should not start during incrementalSyncing(.createPushChannel)") + } + + // WHEN + subject.send(.incrementalSyncing(.createPushChannel)) + _ = await incrementalStarted + + // THEN + #expect(incremental.start_Invocations.count == 1) + #expect(live.start_Invocations.isEmpty) + } + + @Test("liveSyncing(.ongoing) starts live generators (and not incremental ones)") + func liveOngoingStartsLiveGenerators() async { + // GIVEN + let sut = GeneratorsDirectory( + generators: [live, incremental], + syncStatePublisher: subject.eraseToAnyPublisher() + ) + sut.observeSyncState() + + async let liveStarted: Void = waitForCall { resume in + live.start_MockMethod = { resume() } + } + + incremental.start_MockMethod = { + Issue.record("Incremental generator should not start during liveSyncing(.ongoing)") + } + + // WHEN + subject.send(.liveSyncing(.ongoing)) + _ = await liveStarted + + // THEN + #expect(live.start_Invocations.count == 1) + #expect(incremental.start_Invocations.isEmpty) + } + + @Test( + "suspended / liveSyncing(.finished) stops all generators", + arguments: [SyncState.suspended, .liveSyncing(.finished)] + ) + func suspendedAndFinishedStopAllGenerators(state: SyncState) async { + // GIVEN + let sut = GeneratorsDirectory( + generators: [base, live], + syncStatePublisher: subject.eraseToAnyPublisher() + ) + sut.observeSyncState() + + // WHEN + subject.send(.suspended) + + // THEN + #expect(base.stop_Invocations.count == 1) + #expect(live.stop_Invocations.count == 1) + } +} diff --git a/WireDomain/Tests/WireDomainTests/WorkAgent/WorkItem/CommitPendingProposalItemTests.swift b/WireDomain/Tests/WireDomainTests/WorkAgent/WorkItem/CommitPendingProposalItemTests.swift new file mode 100644 index 00000000000..29831ceac5a --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/WorkAgent/WorkItem/CommitPendingProposalItemTests.swift @@ -0,0 +1,87 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +import Testing +import WireDataModel +import WireDataModelSupport +import WireDomainSupport +import WireNetwork +@testable import WireDomain + +class CommitPendingProposalItemTests { + + var sut: CommitPendingProposalItem! + + let repository: MockConversationRepositoryProtocol + let mlsService: MockMLSServiceInterface + let conversationID: WireDataModel.QualifiedID + let mlsGroupID: MLSGroupID + + init() { + self.repository = MockConversationRepositoryProtocol() + self.conversationID = .init(uuid: UUID(), domain: String.randomDomain()) + self.mlsGroupID = .random() + self.mlsService = .init() + mlsService.commitPendingProposalsIn_MockMethod = { _ in } + repository.isSelfAnActiveMemberIn_MockValue = true + } + + private func makeProposalItem() -> CommitPendingProposalItem { + CommitPendingProposalItem( + repository: repository, + conversationID: conversationID, + groupID: mlsGroupID, + mlsService: mlsService + ) + } + + @Test("It calls commitPendingProposal") + func startCallsCommitPendingProposal() async throws { + // Given + sut = makeProposalItem() + // When + try await sut.start() + + // Then + #expect(mlsService.commitPendingProposalsIn_Invocations.count == 1) + } + + @Test("It does not call commitPendingProposal when selfUser is not part of the group") + func startDoesNotCallCommitPendingProposalFuture() async throws { + // Given + repository.isSelfAnActiveMemberIn_MockValue = false + + sut = makeProposalItem() + + // When + try await sut.start() + + // Then + #expect(mlsService.commitPendingProposalsIn_Invocations.isEmpty) + } + + @Test("It logs properly") + func loggingDescription() { + // Given + sut = makeProposalItem() + + // WHEN / THEN + #expect(sut.description == "\(sut!)") + } +} diff --git a/wire-ios-data-model/Source/MLS/MLSService.swift b/wire-ios-data-model/Source/MLS/MLSService.swift index aa33617ef2a..7b8183ca0d3 100644 --- a/wire-ios-data-model/Source/MLS/MLSService.swift +++ b/wire-ios-data-model/Source/MLS/MLSService.swift @@ -80,8 +80,6 @@ public final class MLSService: MLSServiceInterface { case keyPackageQueriedTime } - var pendingProposalCommitTimers = [MLSGroupID: Timer]() - let targetUnclaimedKeyPackageCount = 100 let actionsProvider: MLSActionsProviderProtocol @@ -1380,164 +1378,6 @@ public final class MLSService: MLSServiceInterface { // MARK: - Pending proposals - enum MLSCommitPendingProposalsError: Error { - - case failedToCommitPendingProposals - - } - - private var lastExecutionTime = Date.distantPast - private let throttleInterval: TimeInterval = 2.0 // 2 seconds throttle - - public func commitPendingProposalsIfNeeded() async { - let now = Date.now - - guard now.timeIntervalSince(lastExecutionTime) > throttleInterval else { - return // Ignore call if within the throttle period - } - - lastExecutionTime = now - - await commitPendingProposals() - } - - func commitPendingProposals() async { - guard let context else { - return - } - - logger.info("committing any scheduled pending proposals") - - let groupsWithPendingCommits = await sortedGroupsWithPendingCommits() - - logger.info("\(groupsWithPendingCommits.count) groups with scheduled pending proposals") - - // Committing proposals for each group is independent and should not wait for - // each other. - await withTaskGroup(of: Void.self) { taskGroup in - for (groupID, timestamp) in groupsWithPendingCommits { - taskGroup.addTask { [self] in - do { - if timestamp.isInThePast { - logger.info( - "commit scheduled in the past, committing...", - attributes: [.mlsGroupID: groupID.safeForLoggingDescription] - ) - try await commitPendingProposals(in: groupID) - } else { - logger.info( - "commit scheduled in the future, waiting...", - attributes: [.mlsGroupID: groupID.safeForLoggingDescription] - ) - - let timeIntervalSinceNow = timestamp.timeIntervalSinceNow - if timeIntervalSinceNow > 0 { - try await Task.sleep(nanoseconds: timeIntervalSinceNow.nanoseconds) - } - - let isSelfAnActiveMember = await context.perform { - let conversation = ZMConversation.fetch(with: groupID, in: context) - return conversation?.isSelfAnActiveMember ?? false - } - - guard isSelfAnActiveMember else { - logger.info( - "cancelling commit as the user is no longer a member", - attributes: [.mlsGroupID: groupID.safeForLoggingDescription] - ) - return - } - - logger.info( - "scheduled commit is ready, committing...", - attributes: [.mlsGroupID: groupID.safeForLoggingDescription] - ) - try await commitPendingProposals(in: groupID) - } - - } catch { - logger.error( - "failed to commit pending proposals: \(String(describing: error))", - attributes: [.mlsGroupID: groupID.safeForLoggingDescription] - ) - } - } - } - } - logger.debug("end any scheduled pending proposals") - } - - private func sortedGroupsWithPendingCommits() async -> [(MLSGroupID, Date)] { - guard let context else { - return [] - } - - var result: [(MLSGroupID, Date)] = [] - - let groupIDsAndProposalDatesArray: [(MLSGroupID, Date)] = await context.perform { - ZMConversation.fetchConversationsWithPendingProposals( - in: context - ).filter( - \.isSelfAnActiveMember - ).compactMap { - guard - let groupID = $0.mlsGroupID, - let proposalDate = $0.commitPendingProposalDate - else { - return nil - } - return (groupID, proposalDate) - } - } - - for (groupID, timestamp) in groupIDsAndProposalDatesArray { - // The pending proposal will always fail and cause - // recovery too many recovery syncs. - guard !brokenGroupIDs.contains(groupID) else { - continue - } - - result.append((groupID, timestamp)) - - // The pending proposal might be for the subconversation, - // so include it just in case. - if let subgroupID = await subconversationGroupIDRepository.fetchSubconversationGroupID( - forType: .conference, - parentGroupID: groupID - ) { - result.append((subgroupID, timestamp)) - } - } - - return result.sorted { lhs, rhs in - let (lhsCommitDate, rhsCommitDate) = (lhs.1, rhs.1) - return lhsCommitDate <= rhsCommitDate - } - } - - private func commitPendingProposalsIfNeeded(in groupID: MLSGroupID) async throws { - guard existsPendingProposals(in: groupID) else { return } - // Sending a message while there are pending proposals will result in an error, - // so commit any first. - logger.info("preemptively committing pending proposals in group (\(groupID.safeForLoggingDescription))") - try await commitPendingProposals(in: groupID) - logger.info("success: committed pending proposals in group (\(groupID.safeForLoggingDescription))") - } - - private func existsPendingProposals(in groupID: MLSGroupID) -> Bool { - guard let context else { return false } - - var groupHasPendingProposals = false - - context.performAndWait { - if let conversation = ZMConversation.fetch(with: groupID, in: context) { - groupHasPendingProposals = conversation.commitPendingProposalDate != nil - } - } - - return groupHasPendingProposals - } - public func commitPendingProposals(in groupID: MLSGroupID) async throws { try await retryOnCommitFailure(for: groupID) { [weak self] in try await self?.internalCommitPendingProposals(in: groupID) @@ -1548,7 +1388,7 @@ public final class MLSService: MLSServiceInterface { do { logger.info("committing pending proposals in: \(groupID.safeForLoggingDescription)") try await mlsActionExecutor.commitPendingProposals(in: groupID) - clearPendingProposalCommitDate(for: groupID) + await clearPendingProposalCommitDate(for: groupID) delegate?.mlsServiceDidCommitPendingProposal(for: groupID) } catch { logger @@ -1559,12 +1399,12 @@ public final class MLSService: MLSServiceInterface { } } - private func clearPendingProposalCommitDate(for groupID: MLSGroupID) { + private func clearPendingProposalCommitDate(for groupID: MLSGroupID) async { guard let context else { return } - context.performAndWait { + await context.perform { let conversation = ZMConversation.fetch(with: groupID, in: context) conversation?.commitPendingProposalDate = nil } @@ -1981,6 +1821,13 @@ public final class MLSService: MLSServiceInterface { ) } + public func conferenceSubconversation(parentGroupID: MLSGroupID) async -> MLSGroupID? { + await subconversationGroupIDRepository.fetchSubconversationGroupID( + forType: .conference, + parentGroupID: parentGroupID + ) + } + private func leaveSubconversation( subconversationGroupID: MLSGroupID, parentQualifiedID: QualifiedID, diff --git a/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift b/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift index 53e3e751775..4acc2e77745 100644 --- a/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift +++ b/wire-ios-data-model/Source/MLS/MLSServiceInterface.swift @@ -248,6 +248,11 @@ public protocol MLSServiceInterface: MLSEncryptionServiceInterface, MLSDecryptio parentID: MLSGroupID ) async throws -> MLSGroupID + /// Fetches subConversation of type conference + /// - Parameter parentGroupID: MLSGroupID of the parent conversation + /// - Returns: MLSGroupID of the subconversation + func conferenceSubconversation(parentGroupID: MLSGroupID) async -> MLSGroupID? + /// Leaves the subgroup associated with the given parent conversation /// /// - Parameters: @@ -310,22 +315,6 @@ public protocol MLSServiceInterface: MLSEncryptionServiceInterface, MLSDecryptio // MARK: - Pending proposals - /// Finds groups with pending proposals and commits them if any are found. - /// - /// Starts by getting a list of groups that have pending proposals. - /// This is done by fetching conversations that have a ``ZMConversation/commitPendingProposalDate`` set. - /// We then compile the ``MLSGroupID`` of each conversation and their subconversation - /// (if they exist) associated with the pending proposal date. - /// The groups are then sorted by the date of the pending proposal. - /// - /// We then iterate through each group and check if the pending proposal date has been passed. - /// If it has, we commit the pending proposal by calling ``MLSService/commitPendingProposals(in:)`` - /// Otherwise, we wait until the pending proposal date has passed. Then we commit the pending proposal. - /// - /// [confluence use case](https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/601522340/Use+Case+Committing+pending+proposals+MLS) - - func commitPendingProposalsIfNeeded() async - /// Commits pending proposals for a group. /// /// - Parameter groupID: The group ID to commit pending proposals for diff --git a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Predicates.swift b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Predicates.swift index 60e01e7a569..faee3ed951b 100644 --- a/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Predicates.swift +++ b/wire-ios-data-model/Source/Model/Conversation/ZMConversation+Predicates.swift @@ -20,6 +20,20 @@ import Foundation extension ZMConversation { + public class func sortCommitPendingProsalsByDateAscending() -> NSSortDescriptor { + NSSortDescriptor( + key: commitPendingProposalDateKey, + ascending: true + ) + } + + public class func commitPendingProposalDatePredicate() -> NSPredicate { + NSPredicate( + format: "%K != nil", + argumentArray: [commitPendingProposalDateKey] + ) + } + open override class func predicateForFilteringResults() -> NSPredicate { let selfType = ZMConversationType(rawValue: 1)! return NSPredicate( diff --git a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift index b6cc314e035..2e7ee49b264 100644 --- a/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift +++ b/wire-ios-data-model/Support/Sourcery/generated/AutoMockable.generated.swift @@ -4575,6 +4575,24 @@ public class MockMLSServiceInterface: MLSServiceInterface { } } + // MARK: - conferenceSubconversation + + public var conferenceSubconversationParentGroupID_Invocations: [MLSGroupID] = [] + public var conferenceSubconversationParentGroupID_MockMethod: ((MLSGroupID) async -> MLSGroupID?)? + public var conferenceSubconversationParentGroupID_MockValue: MLSGroupID?? + + public func conferenceSubconversation(parentGroupID: MLSGroupID) async -> MLSGroupID? { + conferenceSubconversationParentGroupID_Invocations.append(parentGroupID) + + if let mock = conferenceSubconversationParentGroupID_MockMethod { + return await mock(parentGroupID) + } else if let mock = conferenceSubconversationParentGroupID_MockValue { + return mock + } else { + fatalError("no mock for `conferenceSubconversationParentGroupID`") + } + } + // MARK: - leaveSubconversation public var leaveSubconversationParentQualifiedIDParentGroupIDSubconversationType_Invocations: [(parentQualifiedID: QualifiedID, parentGroupID: MLSGroupID, subconversationType: SubgroupType)] = [] @@ -4658,21 +4676,6 @@ public class MockMLSServiceInterface: MLSServiceInterface { } } - // MARK: - commitPendingProposalsIfNeeded - - public var commitPendingProposalsIfNeeded_Invocations: [Void] = [] - public var commitPendingProposalsIfNeeded_MockMethod: (() async -> Void)? - - public func commitPendingProposalsIfNeeded() async { - commitPendingProposalsIfNeeded_Invocations.append(()) - - guard let mock = commitPendingProposalsIfNeeded_MockMethod else { - fatalError("no mock for `commitPendingProposalsIfNeeded`") - } - - await mock() - } - // MARK: - commitPendingProposals public var commitPendingProposalsIn_Invocations: [MLSGroupID] = [] diff --git a/wire-ios-data-model/Tests/MLS/MLSServiceTests.swift b/wire-ios-data-model/Tests/MLS/MLSServiceTests.swift index a91d2ae45b9..d4bcc8ad602 100644 --- a/wire-ios-data-model/Tests/MLS/MLSServiceTests.swift +++ b/wire-ios-data-model/Tests/MLS/MLSServiceTests.swift @@ -1021,411 +1021,6 @@ final class MLSServiceTests: ZMConversationTestsBase, MLSServiceDelegate { } } - // MARK: - Pending proposals - - func test_CommitPendingProposalsIfNeeded_It_Discards_Calls_Within_The_Throttle_Interval() async throws { - // Given - let futureCommitDate = Date().addingTimeInterval(2) - let groupID = MLSGroupID(.init([1, 2, 3])) - - await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // A group with pending proposal in the future - let conversation = createConversation(in: uiMOC) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = futureCommitDate - conversation.addParticipantAndUpdateConversationState(user: selfUser) - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal. - mockMLSActionExecutor.mockCommitPendingProposals = { _ in } - - // When - await withTaskGroup(of: Void.self) { group in - for _ in 0 ..< 10 { // Simulating 10 rapid calls - group.addTask { - await self.sut.commitPendingProposalsIfNeeded() - } - - try? await Task.sleep( - nanoseconds: 100_000_000 - ) // 0.1s delay between each call - } - } - - // Then, 9 calls within the throttle interval have been discarded, only 1 went through. - XCTAssertEqual(mockMLSActionExecutor.commitPendingProposalsCount, 1) - } - - func test_CommitPendingProposals_NoProposalsExist() async throws { - // Given - let overdueCommitDate = Date().addingTimeInterval(-5) - let groupID = MLSGroupID.random() - - let conversationOID = await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // A group with pending proposal in the past. - let conversation = createConversation(in: uiMOC) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = overdueCommitDate - conversation.addParticipantAndUpdateConversationState(user: selfUser) - - return conversation.objectID - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock no pending proposals. - mockMLSActionExecutor.mockCommitPendingProposals = { _ in } - - // When - await sut.commitPendingProposals() - - // Then we cleared the pending proposal date. - try await uiMOC.perform { [uiMOC] in - let conversation = try XCTUnwrap(uiMOC.object(with: conversationOID) as? ZMConversation) - XCTAssertNil(conversation.commitPendingProposalDate) - } - } - - func test_CommitPendingProposals_OneOverdueCommit() async throws { - // Given - let overdueCommitDate = Date().addingTimeInterval(-5) - let groupID = MLSGroupID.random() - - let conversationOID = await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // Create conversation with pending proposal in the future - let conversation = createConversation(in: uiMOC) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = overdueCommitDate - conversation.addParticipantAndUpdateConversationState(user: selfUser) - - return conversation.objectID - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal. - var mockCommitPendingProposalArguments = [(MLSGroupID, Date)]() - - mockMLSActionExecutor.mockCommitPendingProposals = { - mockCommitPendingProposalArguments.append(($0, Date())) - } - - // When - await sut.commitPendingProposals() - - // Then we committed the pending proposal immediately. - let (id, commitTime) = try XCTUnwrap(mockCommitPendingProposalArguments.first) - XCTAssertEqual(mockCommitPendingProposalArguments.count, 1) - XCTAssertEqual(id, groupID) - XCTAssertEqual(commitTime.timeIntervalSinceNow, Date().timeIntervalSinceNow, accuracy: 0.1) - - try await uiMOC.perform { [self] in - let conversation = try XCTUnwrap(uiMOC.object(with: conversationOID) as? ZMConversation) - XCTAssertNil(conversation.commitPendingProposalDate) - } - } - - func test_CommitPendingProposals_OneFutureCommit() async throws { - // Given - let futureCommitDate = Date().addingTimeInterval(2) - let groupID = MLSGroupID(.init([1, 2, 3])) - - let conversationOID = await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // Create conversation with pending proposal in the future - let conversation = createConversation(in: uiMOC) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = futureCommitDate - conversation.addParticipantAndUpdateConversationState(user: selfUser) - - return conversation.objectID - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal. - var mockCommitPendingProposalArguments = [(MLSGroupID, Date)]() - - mockMLSActionExecutor.mockCommitPendingProposals = { - mockCommitPendingProposalArguments.append(($0, Date())) - } - - // When - await sut.commitPendingProposals() - - // Then we committed the proposal at the right time. - let (id, commitTime) = try XCTUnwrap(mockCommitPendingProposalArguments.first) - XCTAssertEqual(mockCommitPendingProposalArguments.count, 1) - XCTAssertEqual(id, groupID) - XCTAssertEqual(commitTime.timeIntervalSinceNow, futureCommitDate.timeIntervalSinceNow, accuracy: 0.1) - - try await uiMOC.perform { [self] in - let conversation = try XCTUnwrap(uiMOC.object(with: conversationOID) as? ZMConversation) - XCTAssertNil(conversation.commitPendingProposalDate) - } - } - - func test_CommitPendingProposals_ExcludesGroupsWhereSelfUserIsNotAMember() async throws { - // Given - let groupID = MLSGroupID(.init([1, 2, 3])) - let commitDate = Date().addingTimeInterval(-1) // Past date - - let context = uiMOC - - let conversationOID = await context.perform { [self] in - let conversation = createConversation(in: context) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = commitDate - conversation.conversationType = .group - return conversation.objectID - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal - var committedGroupIDs: [MLSGroupID] = [] - mockMLSActionExecutor.mockCommitPendingProposals = { groupID in - committedGroupIDs.append(groupID) - } - - // When - await sut.commitPendingProposals() - - // Then the conversation should be excluded and no commits should happen - XCTAssertTrue(committedGroupIDs.isEmpty) - - // Verify self user is not a member - try await context.perform { - let conversation = try XCTUnwrap(context.existingObject(with: conversationOID) as? ZMConversation) - XCTAssertFalse(conversation.isSelfAnActiveMember) - XCTAssertNotNil(conversation.commitPendingProposalDate) - } - } - - func test_CommitPendingProposals_CancelsCommitWhenSelfUserRemovedDuringDelay() async throws { - // Given - let futureCommitDate = Date().addingTimeInterval(1.5) - let groupID = MLSGroupID(.init([1, 2, 3])) - let context = uiMOC - - let (conversationOID, selfUserOID) = await context.perform { [self] in - let selfUser = ZMUser.selfUser(in: context) - - // Create conversation with pending proposal in the future - let conversation = createConversation(in: context) - conversation.mlsGroupID = groupID - conversation.commitPendingProposalDate = futureCommitDate - conversation.conversationType = .group - - // Self user is initially a member - conversation.addParticipantAndUpdateConversationState(user: selfUser) - - return (conversation.objectID, selfUser.objectID) - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal - var committedGroupIDs: [MLSGroupID] = [] - mockMLSActionExecutor.mockCommitPendingProposals = { groupID in - committedGroupIDs.append(groupID) - } - - // Schedule removal of self user after 0.5 seconds (before commit time) - Task { - do { - try await Task.sleep(for: .milliseconds(500)) // 0.5 seconds - try await context.perform { - let conversation = try XCTUnwrap(context.object(with: conversationOID) as? ZMConversation) - let selfUser = try XCTUnwrap(context.object(with: selfUserOID) as? ZMUser) - conversation.removeParticipantAndUpdateConversationState(user: selfUser) - } - } catch { - XCTFail("test failed with error: \(error)") - } - } - - // When - await sut.commitPendingProposals() - - // Then the commit should be cancelled - XCTAssertTrue(committedGroupIDs.isEmpty, "No commits should have been executed") - - // Verify self user is no longer a member - try await context.perform { - let conversation = try XCTUnwrap(context.object(with: conversationOID) as? ZMConversation) - XCTAssertFalse(conversation.isSelfAnActiveMember) - } - } - - func test_CommitPendingProposals_MultipleCommits() async throws { - // Given - let overdueCommitDate = Date().addingTimeInterval(-5) - let futureCommitDate1 = Date().addingTimeInterval(2) - let futureCommitDate2 = Date().addingTimeInterval(5) - - let conversation1MLSGroupID = MLSGroupID(.init([1, 2, 3])) - let conversation2MLSGroupID = MLSGroupID(.init([4, 5, 6])) - let conversation3MLSGroupID = MLSGroupID(.init([7, 8, 9])) - - let objectIDs = await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // A group with pending proposal in the past - let conversation1 = createConversation(in: uiMOC) - conversation1.mlsGroupID = conversation1MLSGroupID - conversation1.commitPendingProposalDate = overdueCommitDate - conversation1.addParticipantAndUpdateConversationState(user: selfUser) - - // A group with pending proposal in the future - let conversation2 = createConversation(in: uiMOC) - conversation2.mlsGroupID = conversation2MLSGroupID - conversation2.commitPendingProposalDate = futureCommitDate1 - conversation2.addParticipantAndUpdateConversationState(user: selfUser) - - // A group with pending proposal in the future - let conversation3 = createConversation(in: uiMOC) - conversation3.mlsGroupID = conversation3MLSGroupID - conversation3.commitPendingProposalDate = futureCommitDate2 - conversation3.addParticipantAndUpdateConversationState(user: selfUser) - - return ( - id1: conversation1.objectID, - id2: conversation2.objectID, - id3: conversation3.objectID - ) - } - - // Mock no subconversations - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = .some(nil) - - // Mock committing pending proposal. - var mockCommitPendingProposalArguments = [(MLSGroupID, Date)]() - - mockMLSActionExecutor.mockCommitPendingProposals = { - mockCommitPendingProposalArguments.append(($0, Date())) - } - - // When - await sut.commitPendingProposals() - - // Then pending proposals were committed in order at the right times. - guard mockCommitPendingProposalArguments.count == 3 else { - return XCTFail("mockCommitPendingProposalArguments.count != 3") - } - - // Commit 1 - let (id1, commitTime1) = try XCTUnwrap(mockCommitPendingProposalArguments.first) - XCTAssertEqual(id1, conversation1MLSGroupID) - XCTAssertEqual( - commitTime1.timeIntervalSinceNow, - overdueCommitDate.addingTimeInterval(5).timeIntervalSinceNow, - accuracy: 0.1 - ) - - // Commit 2 - let (id2, commitTime2) = try XCTUnwrap(mockCommitPendingProposalArguments[1]) - XCTAssertEqual(id2, conversation2MLSGroupID) - XCTAssertEqual( - commitTime2.timeIntervalSinceNow, - futureCommitDate1.timeIntervalSinceNow, - accuracy: 0.1 - ) - - // Commit 3 - let (id3, commitTime3) = try XCTUnwrap(mockCommitPendingProposalArguments[2]) - XCTAssertEqual(id3, conversation3MLSGroupID) - XCTAssertEqual( - commitTime3.timeIntervalSinceNow, - futureCommitDate2.timeIntervalSinceNow, - accuracy: 0.1 - ) - - // Then all conversations have no more commit dates. - try await uiMOC.perform { [uiMOC] in - let conversation1 = try XCTUnwrap(uiMOC.object(with: objectIDs.id1) as? ZMConversation) - let conversation2 = try XCTUnwrap(uiMOC.object(with: objectIDs.id2) as? ZMConversation) - let conversation3 = try XCTUnwrap(uiMOC.object(with: objectIDs.id3) as? ZMConversation) - - XCTAssertNil(conversation1.commitPendingProposalDate) - XCTAssertNil(conversation2.commitPendingProposalDate) - XCTAssertNil(conversation3.commitPendingProposalDate) - } - } - - func test_CommitPendingProposals_ForSubconversation() async throws { - // Given - let overdueCommitDate = Date().addingTimeInterval(-5) - let parentGroupdID = MLSGroupID.random() - let subgroupID = MLSGroupID.random() - - let conversationOID = await uiMOC.perform { [self] in - let selfUser = ZMUser.selfUser(in: uiMOC) - - // A group with pending proposal in the past. - let conversation = createConversation(in: uiMOC) - conversation.mlsGroupID = parentGroupdID - conversation.commitPendingProposalDate = overdueCommitDate - conversation.addParticipantAndUpdateConversationState(user: selfUser) - - return conversation.objectID - } - - // Mock subconversation - mockSubconversationGroupIDRepository.fetchSubconversationGroupIDForTypeParentGroupID_MockValue = subgroupID - - // Mock committing pending proposal. - let commitPendingProposalsArgumentsActor = GenericArrayActor<(MLSGroupID, Date)>() - mockMLSActionExecutor.mockCommitPendingProposals = { - await commitPendingProposalsArgumentsActor.append(($0, Date())) - } - - // When - await sut.commitPendingProposals() - - // Then we asked for the subgroup id - let subgroupInvocations = mockSubconversationGroupIDRepository - .fetchSubconversationGroupIDForTypeParentGroupID_Invocations - XCTAssertEqual(subgroupInvocations.count, 1) - XCTAssertEqual(subgroupInvocations.first?.type, .conference) - XCTAssertEqual(subgroupInvocations.first?.parentGroupID, parentGroupdID) - - // Then we try to commit pending proposals twice, once for the subgroup, once for the parent - let mockCommitPendingProposalArguments = await commitPendingProposalsArgumentsActor.items - XCTAssertEqual(mockCommitPendingProposalArguments.count, 2) - let (id1, commitTime1) = try XCTUnwrap(mockCommitPendingProposalArguments.first) - - // there is no guarantee which proposal is finished first - XCTAssertTrue([subgroupID, parentGroupdID].contains(id1)) - XCTAssertEqual(commitTime1.timeIntervalSinceNow, Date().timeIntervalSinceNow, accuracy: 0.1) - - let (id2, commitTime2) = try XCTUnwrap(mockCommitPendingProposalArguments.last) - - // there is no guarantee which proposal is finished first - XCTAssertTrue([subgroupID, parentGroupdID].contains(id2)) - XCTAssertEqual(commitTime2.timeIntervalSinceNow, Date().timeIntervalSinceNow, accuracy: 0.1) - - try await uiMOC.perform { [uiMOC] in - let conversation = try XCTUnwrap(uiMOC.object(with: conversationOID) as? ZMConversation) - XCTAssertNil(conversation.commitPendingProposalDate) - } - } - // MARK: - Joining conversations func test_PerformPendingJoins_It_Establishes_Group() async throws { diff --git a/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoder+MLS.swift b/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoder+MLS.swift index 7fbc650886b..2894ac8bc0c 100644 --- a/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoder+MLS.swift +++ b/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoder+MLS.swift @@ -142,15 +142,6 @@ extension EventDecoder { } } - if let mlsService = await context.perform({ context.mlsService }), - updateEvent.source == .webSocket { - Task.detached { [mlsService] in - // we don't need to wait for this, as it can take a while to finish - // it should not block decryption - await mlsService.commitPendingProposalsIfNeeded() - } - } - return events } diff --git a/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoderTest.swift b/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoderTest.swift index 35e6d17fe78..87bffcf2076 100644 --- a/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoderTest.swift +++ b/wire-ios-request-strategy/Sources/Synchronization/Decoding/EventDecoderTest.swift @@ -51,7 +51,6 @@ class EventDecoderTest: MessagingTestBase { lastEventIDRepository.storeLastEventID_MockMethod = { _ in } syncMOC.performGroupedAndWait { - self.mockMLSService.commitPendingProposalsIfNeeded_MockMethod = {} self.syncMOC.mlsService = self.mockMLSService let selfUser = ZMUser.selfUser(in: self.syncMOC) selfUser.remoteIdentifier = self.accountIdentifier @@ -738,8 +737,6 @@ extension EventDecoderTest { // Then XCTAssertTrue(decryptedEvents.isEmpty) - wait(forConditionToBeTrue: !self.mockMLSService.commitPendingProposalsIfNeeded_Invocations.isEmpty, timeout: 3) - XCTAssertEqual(1, mockMLSService.commitPendingProposalsIfNeeded_Invocations.count) } func test_DecryptMLSMessage_CommitsPendingsProposalsIsNotCalled_WhenReceivingProposalViaDownload() async throws { @@ -762,8 +759,6 @@ extension EventDecoderTest { // Then XCTAssertTrue(decryptedEvents.isEmpty) - spinMainQueue(withTimeout: 1) - XCTAssertTrue(mockMLSService.commitPendingProposalsIfNeeded_Invocations.isEmpty) } func test_DecryptMLSMessage_ReturnsNoEvent_WhenPayloadIsInvalid() async throws { diff --git a/wire-ios-sync-engine/Source/Synchronization/SyncAgent.swift b/wire-ios-sync-engine/Source/Synchronization/SyncAgent.swift index ed08c1c94d5..e0bcf48aec3 100644 --- a/wire-ios-sync-engine/Source/Synchronization/SyncAgent.swift +++ b/wire-ios-sync-engine/Source/Synchronization/SyncAgent.swift @@ -68,8 +68,6 @@ final class SyncAgent: NSObject, SyncAgentProtocol { private let initialSyncTaskManager = NonReentrantTaskManager() private var incrementalSyncToken: IncrementalSync.Token? private var ongoingSyncTask: Task? - private let conversationUpdatesGenerator: ConversationUpdatesGeneratorProtocol - private var cancellables: Set = .init() var syncRunning: Bool { @@ -96,7 +94,6 @@ final class SyncAgent: NSObject, SyncAgentProtocol { featureConfigRepository: any FeatureConfigRepositoryProtocol, syncStateSubject: CurrentValueSubject, pushChannelCoordinator: any MainAppPushChannelCoordinatorProtocol, - conversationUpdatesGenerator: any ConversationUpdatesGeneratorProtocol, networkStatePublisher: AnyPublisher ) { self.journal = journal @@ -108,7 +105,6 @@ final class SyncAgent: NSObject, SyncAgentProtocol { self.featureConfigRepository = featureConfigRepository self.syncStateSubject = syncStateSubject self.pushChannelCoordinator = pushChannelCoordinator - self.conversationUpdatesGenerator = conversationUpdatesGenerator self.networkStatePublisher = networkStatePublisher super.init() @@ -157,7 +153,6 @@ final class SyncAgent: NSObject, SyncAgentProtocol { WireLogger.sync.debug( "suspending sync \(backgroundActivity != nil ? "in a background task" : "")" ) - conversationUpdatesGenerator.stop() ongoingSyncTask?.cancel() ongoingSyncTask = nil await incrementalSyncToken?.suspend() @@ -224,10 +219,6 @@ final class SyncAgent: NSObject, SyncAgentProtocol { /// Perform an incremental sync. func performIncrementalSync() async throws { - Task { - await conversationUpdatesGenerator.start() - } - if isSyncV2Enabled { try await incrementalSyncTaskManager.performIfNeeded { [weak self] in diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index c4299b14568..db5da49131d 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -638,7 +638,6 @@ public final class ZMUserSession: NSObject { featureConfigRepository: clientSessionComponent.featureConfigRepository, syncStateSubject: clientSessionComponent.syncStateSubject, pushChannelCoordinator: clientSessionComponent.mainAppPushChannelCoordinator, - conversationUpdatesGenerator: clientSessionComponent.conversationUpdatesGenerator, networkStatePublisher: networkStateSubject.eraseToAnyPublisher() ) applicationStatusDirectory.syncStatus.syncStateDelegate = syncAgent @@ -670,6 +669,7 @@ public final class ZMUserSession: NSObject { Task { await clientSessionComponent.workAgent.setAutoStartEnabled(true) await clientSessionComponent.workAgent.start() + clientSessionComponent.generatorsDirectory.observeSyncState() } } @@ -1245,21 +1245,6 @@ extension ZMUserSession: SyncAgentDelegate { context: notificationContext ).post() } - - syncContext.perform { [weak self] in - guard let self else { return } - let selfClient = ZMUser.selfUser(in: syncContext).selfClient() - - if selfClient?.hasRegisteredMLSClient == true { - Task { - do { - try await self.mlsService.repairOutOfSyncConversations() - } catch { - WireLogger.mls.error("Repairing out of sync conversations failed: \(error)") - } - } - } - } } func didStartIncrementalSync() { @@ -1307,13 +1292,6 @@ extension ZMUserSession: SyncAgentDelegate { WireLogger.mls.warn("`qualifiedClientID` is missing for selfClient") } - if !isRecovering, mlsFeature.isEnabled { - Task.detached { [mlsService] in - // we don't need to wait for this, as it can take a while to finish - await mlsService.commitPendingProposalsIfNeeded() - } - } - await calculateSelfSupportedProtocolsIfNeeded() await resolveOneOnOneConversationsIfNeeded() diff --git a/wire-ios-sync-engine/Tests/Source/Calling/MLSConferenceStaleParticipantsRemoverTests.swift b/wire-ios-sync-engine/Tests/Source/Calling/MLSConferenceStaleParticipantsRemoverTests.swift index f5a5855c0cb..44171ddf4e7 100644 --- a/wire-ios-sync-engine/Tests/Source/Calling/MLSConferenceStaleParticipantsRemoverTests.swift +++ b/wire-ios-sync-engine/Tests/Source/Calling/MLSConferenceStaleParticipantsRemoverTests.swift @@ -216,7 +216,7 @@ class MLSConferenceStaleParticipantsRemoverTests: MessagingTest { from participants: [MLSParticipant] ) -> [MLSClientID: XCTestExpectation] { participants.reduce(into: [MLSClientID: XCTestExpectation]()) { expectations, participant in - var expectation = switch participant.callParticipant.state { + let expectation = switch participant.callParticipant.state { case .connecting: XCTestExpectation(description: "removed stale participant (\(participant.mlsClientID))") default: diff --git a/wire-ios-sync-engine/Tests/Source/MessagingTest+Swift.swift b/wire-ios-sync-engine/Tests/Source/MessagingTest+Swift.swift index f6a1fca477e..ba242443b18 100644 --- a/wire-ios-sync-engine/Tests/Source/MessagingTest+Swift.swift +++ b/wire-ios-sync-engine/Tests/Source/MessagingTest+Swift.swift @@ -97,6 +97,7 @@ public extension MessagingTest { } @objc + @discardableResult func createSelfClient() -> UserClient { createSelfClient(capabilities: []) } diff --git a/wire-ios-sync-engine/Tests/Source/MessagingTest.h b/wire-ios-sync-engine/Tests/Source/MessagingTest.h index 513ed6cd7d7..bb409f31f17 100644 --- a/wire-ios-sync-engine/Tests/Source/MessagingTest.h +++ b/wire-ios-sync-engine/Tests/Source/MessagingTest.h @@ -89,7 +89,6 @@ NS_ASSUME_NONNULL_BEGIN @interface MessagingTest (OTR) - (UserClient *)setupSelfClientInMoc:(NSManagedObjectContext *)moc; -- (UserClient *)createSelfClient; @end diff --git a/wire-ios-sync-engine/Tests/Source/Registration/CompanyLoginRequesterTests.swift b/wire-ios-sync-engine/Tests/Source/Registration/CompanyLoginRequesterTests.swift index 4dc8e71d305..26f0e80b989 100644 --- a/wire-ios-sync-engine/Tests/Source/Registration/CompanyLoginRequesterTests.swift +++ b/wire-ios-sync-engine/Tests/Source/Registration/CompanyLoginRequesterTests.swift @@ -200,7 +200,7 @@ final class CompanyLoginRequesterTests: XCTestCase { private final class MockSession: NSObject, URLSessionProtocol { - class MockURLSessionDataTask: URLSessionDataTask { + class MockURLSessionDataTask: URLSessionDataTask, @unchecked Sendable { override func resume() { // no-op } diff --git a/wire-ios-sync-engine/Tests/Source/Synchronization/Strategies/CallingRequestStrategyTests.swift b/wire-ios-sync-engine/Tests/Source/Synchronization/Strategies/CallingRequestStrategyTests.swift index ecb7a1a36c1..424ee67024b 100644 --- a/wire-ios-sync-engine/Tests/Source/Synchronization/Strategies/CallingRequestStrategyTests.swift +++ b/wire-ios-sync-engine/Tests/Source/Synchronization/Strategies/CallingRequestStrategyTests.swift @@ -596,7 +596,7 @@ class CallingRequestStrategyTests: MessagingTest { } func testThatItDoesNotTargetCallMessagesIfNoTargetClientsAreSpecified() async throws { - let (user1, user2, client1, client2, client3, client4, conversationAVSID) = try await syncMOC + let (_, _, _, _, _, _, conversationAVSID) = try await syncMOC .perform { [self] in // Given let selfClient = createSelfClient() diff --git a/wire-ios-sync-engine/Tests/Source/Synchronization/SyncAgentTests.swift b/wire-ios-sync-engine/Tests/Source/Synchronization/SyncAgentTests.swift index cf8ae933b9c..574c74bf9c1 100644 --- a/wire-ios-sync-engine/Tests/Source/Synchronization/SyncAgentTests.swift +++ b/wire-ios-sync-engine/Tests/Source/Synchronization/SyncAgentTests.swift @@ -40,7 +40,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv var backgroundActivityManager: MockBackgroundActivityManager! var featureConfigRepository: MockFeatureConfigRepositoryProtocol! var mainAppPushChannelCoordinator: MockMainAppPushChannelCoordinatorProtocol! - var conversationUpdatesGenerator: MockConversationUpdatesGeneratorProtocol! var incrementalSyncDidFinish: XCTestExpectation! @@ -63,9 +62,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv backgroundActivity.activityManager = backgroundActivityManager featureConfigRepository = MockFeatureConfigRepositoryProtocol() mainAppPushChannelCoordinator = MockMainAppPushChannelCoordinatorProtocol() - conversationUpdatesGenerator = MockConversationUpdatesGeneratorProtocol() - conversationUpdatesGenerator.start_MockMethod = {} - conversationUpdatesGenerator.stop_MockMethod = {} sut = SyncAgent( journal: journal, @@ -77,7 +73,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv featureConfigRepository: featureConfigRepository, syncStateSubject: syncStateSubject, pushChannelCoordinator: mainAppPushChannelCoordinator, - conversationUpdatesGenerator: conversationUpdatesGenerator, networkStatePublisher: networkStateSubject.eraseToAnyPublisher() ) @@ -98,7 +93,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv backgroundActivity = nil featureConfigRepository = nil mainAppPushChannelCoordinator = nil - conversationUpdatesGenerator = nil incrementalSyncDidFinish = nil } @@ -129,7 +123,7 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv networkStateSubject.send(.online) // THEN - wait(for: [incrementalSyncDidFinish], timeout: 2) + await fulfillment(of: [incrementalSyncDidFinish], timeout: 2) } func testPerformSyncIfNeeded_InitialSync() async throws { @@ -308,8 +302,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv } catch {} await fulfillment(of: [expectation]) - - try XCTAssertCount(conversationUpdatesGenerator.start_Invocations, count: 1) } func testSuspend_Sync_State_Update_To_Suspended_And_Background_Task_Is_Active() async throws { @@ -346,8 +338,6 @@ final class SyncAgentTests: XCTestCase, InitialSyncProvider, IncrementalSyncProv await sut.suspend() await fulfillment(of: [expectation]) - - try XCTAssertCount(conversationUpdatesGenerator.stop_Invocations, count: 1) } func provideLiveSync(delegate: any WireDomain.LiveSyncDelegate) throws -> any WireDomain.LiveSyncProtocol { diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/SessionManagerEncryptionAtRestMigrationTests.swift b/wire-ios-sync-engine/Tests/Source/UserSession/SessionManagerEncryptionAtRestMigrationTests.swift index e3841e37629..2ea60de556b 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/SessionManagerEncryptionAtRestMigrationTests.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/SessionManagerEncryptionAtRestMigrationTests.swift @@ -96,7 +96,7 @@ final class SessionManagerEncryptionAtRestMigrationTests: ZMUserSessionTestsBase ModelHelper().createSelfClient(in: syncMOC) syncMOC.saveOrRollback() } - try userSession.viewContext.saveOrRollback() + userSession.viewContext.saveOrRollback() } // @SF.Storage @TSFI.UserInterface @S0.1 @S0.2 diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests.swift b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests.swift index 2f26e382e1d..12ae070f5cc 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTests.swift @@ -420,7 +420,6 @@ final class ZMUserSessionTests: ZMUserSessionTestsBase { self.sut.featureRepository.storeMLS(mls) } mockMLSService.performPendingJoins_MockMethod = {} - mockMLSService.commitPendingProposalsIfNeeded_MockMethod = {} mockMLSService.uploadKeyPackagesIfNeeded_MockMethod = {} mockMLSService.updateKeyMaterialForAllStaleGroupsIfNeeded_MockMethod = {} mockCoreCryptoProvider.registerMlsTransport_MockMethod = { _ in } @@ -458,7 +457,6 @@ final class ZMUserSessionTests: ZMUserSessionTestsBase { XCTAssertFalse(mockMLSService.performPendingJoins_Invocations.isEmpty) XCTAssertFalse(mockMLSService.uploadKeyPackagesIfNeeded_Invocations.isEmpty) XCTAssertFalse(mockMLSService.updateKeyMaterialForAllStaleGroupsIfNeeded_Invocations.isEmpty) - XCTAssertFalse(mockMLSService.commitPendingProposalsIfNeeded_Invocations.isEmpty) XCTAssertEqual(mockRecurringActionService.performActionsIfNeeded_Invocations.count, 1) @@ -477,7 +475,6 @@ final class ZMUserSessionTests: ZMUserSessionTestsBase { } mockMLSService.performPendingJoins_MockMethod = {} - mockMLSService.commitPendingProposalsIfNeeded_MockMethod = {} mockMLSService.uploadKeyPackagesIfNeeded_MockMethod = {} mockMLSService.updateKeyMaterialForAllStaleGroupsIfNeeded_MockMethod = {} mockCoreCryptoProvider.initialiseMLSWithBasicCredentialsMlsClientID_MockMethod = { _ in } diff --git a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift index 20054bcac4d..1ad2ff46698 100644 --- a/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift +++ b/wire-ios-sync-engine/Tests/Source/UserSession/ZMUserSessionTestsBase.swift @@ -104,7 +104,6 @@ class ZMUserSessionTestsBase: MessagingTest { mockEARService.setInitialEARFlagValue_MockMethod = { _ in } mockMLSService = MockMLSServiceInterface() - mockMLSService.commitPendingProposalsIfNeeded_MockMethod = {} mockMLSService.onNewCRLsDistributionPoints_MockValue = PassthroughSubject() .eraseToAnyPublisher() mockMLSService.epochChanges_MockValue = .init { continuation in diff --git a/wire-ios-sync-engine/WireSyncEngine.xcodeproj/project.pbxproj b/wire-ios-sync-engine/WireSyncEngine.xcodeproj/project.pbxproj index ad7e8d1b0ca..f02e2291653 100644 --- a/wire-ios-sync-engine/WireSyncEngine.xcodeproj/project.pbxproj +++ b/wire-ios-sync-engine/WireSyncEngine.xcodeproj/project.pbxproj @@ -1302,6 +1302,13 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0149B6172EE1C8510081EA8A /* Generators */ = { + isa = PBXGroup; + children = ( + ); + path = Generators; + sourceTree = ""; + }; 060C06662B7619C600B484C6 /* E2EI */ = { isa = PBXGroup; children = ( @@ -1653,6 +1660,7 @@ F19F1D361EFBF60A00275E27 /* SessionManager */, F19F1D121EFBC17A00275E27 /* UnauthenticatedSession */, 3E89FFA3191B6E15002D3A3E /* Data Model */, + 0149B6172EE1C8510081EA8A /* Generators */, 85D85DBFC1F3A95767DEEA45 /* Synchronization */, 3EAD6A08199BB6C800D519DB /* Calling */, EE88B0522BF62B430013F0BD /* Services */,