diff --git a/wire-ios-data-model/Source/ConversationList/ConversationDirectory.swift b/wire-ios-data-model/Source/ConversationList/ConversationDirectory.swift index fd4bddc03e5..40f15916850 100644 --- a/wire-ios-data-model/Source/ConversationList/ConversationDirectory.swift +++ b/wire-ios-data-model/Source/ConversationList/ConversationDirectory.swift @@ -67,6 +67,7 @@ public protocol ConversationDirectoryType { /// NOTE that returned token must be retained for as long you want the observer to be active func addObserver(_ observer: ConversationDirectoryObserver) -> Any + func refetchAllLists(in managedObjectContext: NSManagedObjectContext) } extension ZMConversationListDirectory: ConversationDirectoryType { diff --git a/wire-ios-data-model/Source/EntityValidation/ConnectionValidator.swift b/wire-ios-data-model/Source/EntityValidation/ConnectionValidator.swift new file mode 100644 index 00000000000..c56fe79c882 --- /dev/null +++ b/wire-ios-data-model/Source/EntityValidation/ConnectionValidator.swift @@ -0,0 +1,249 @@ +// +// 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 CoreData +import WireLogging + +/// An object responsible for correcting invalid state regarding +/// user connections. + +public class ConnectionValidator { + + struct SearchResult { + let invalidConnections: [NSManagedObjectID] + let connectionsToCancel: [NSManagedObjectID] + let connectionsToIgnore: [NSManagedObjectID] + + init( + invalidConnections: [NSManagedObjectID] = [], + connectionsToCancel: [NSManagedObjectID] = [], + connectionsToIgnore: [NSManagedObjectID] = [] + ) { + self.invalidConnections = invalidConnections + self.connectionsToCancel = connectionsToCancel + self.connectionsToIgnore = connectionsToIgnore + } + } + + public enum Failure: Error { + + case userNotFound + + } + + private let context: NSManagedObjectContext + + /// Create a new `ConnectionValidator`. + + public init(context: NSManagedObjectContext) { + self.context = context + } + + /// Clean up all invalid connections. + /// + /// Invoking this method will search the local database for invalid + /// connections and reject them all. As a result, only connections will + /// remain in the database. + /// + /// A connection is considered invalid if it is pending (i.e not accepted, + /// not blocked) and between the self user and a fellow team member. This + /// can happen if the pending connection exists between the self user + /// and another user while both users are not in the same team, but then + /// later become part of the same team (e.g via invitation). Already + /// established connections with users that later become team members are + /// honored, and any new communciation with team members are via implicit + /// team connections. + + public func cleanUpAllInvalidConnections() async throws { + let teamID = await context.perform { [context] in + ZMUser.selfUser(in: context).teamIdentifier + } + + // If there's no self team, all connections are consider + // valid. + guard let teamID else { + return + } + + // Fetch ids of invalid connections. + let searchResult = try await context.perform { [context] in + let fetchRequest = NSFetchRequest(entityName: ZMConnection.entityName()) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "NOT (status IN %@)", Self.keepConnectionStatuses().map(\.rawValue)), + NSPredicate( + format: "to != nil AND to.teamIdentifier_data == %@", + teamID.uuidData as NSData + ) // TODO: [WPB-15469] Is `to != nil` necessary? + ]) + + let connections = try context.fetch(fetchRequest) + var invalidConnections: [NSManagedObjectID] = [] + var connectionsToCancel: [NSManagedObjectID] = [] + var connectionsToIgnore: [NSManagedObjectID] = [] + + for invalidConnection in connections { + invalidConnections.append(invalidConnection.objectID) + switch invalidConnection.status { + case .pending: + connectionsToIgnore.append(invalidConnection.objectID) + case .sent: + connectionsToCancel.append(invalidConnection.objectID) + default: + // TODO: [WPB-15469] Consider blocked for legal hold? + break + } + } + + return SearchResult( + invalidConnections: invalidConnections, + connectionsToCancel: connectionsToCancel, + connectionsToIgnore: connectionsToIgnore + ) + } + + try await cleanUpState(for: searchResult) + } + + /// Clean up the invalid connection to the given user if needed. + /// + /// - Parameter userObjectID: An object id to another user. + + public func cleanUpInvalidConnectionIfNeeded(userObjectID: NSManagedObjectID) async throws { + let searchResult = try await context.perform { [context] in + let selfUser = ZMUser.selfUser(in: context) + + // If there is no team, there are no invalid connections. + guard let teamID = selfUser.teamIdentifier else { + return SearchResult() + } + + guard let user = try context.existingObject(with: userObjectID) as? ZMUser else { + throw Failure.userNotFound + } + + // Ensure this is an invalid connection. + guard + user.teamIdentifier == teamID, + let connection = user.connection, + !connection.status.isOne(of: .accepted, .blocked) + else { + return SearchResult() + } + + let invalidConnections = [connection.objectID] + var connectionsToCancel: [NSManagedObjectID] = [] + var connectionsToIgnore: [NSManagedObjectID] = [] + + switch connection.status { + case .sent: + connectionsToCancel = [connection.objectID] + case .pending: + connectionsToIgnore = [connection.objectID] + default: + // TODO: [WPB-15469] Consider blocked for legal hold? + break + } + + return SearchResult( + invalidConnections: invalidConnections, + connectionsToCancel: connectionsToCancel, + connectionsToIgnore: connectionsToIgnore + ) + } + + try await cleanUpState(for: searchResult) + } + + private func cleanUpState(for searchResult: SearchResult) async throws { + var allInvalidConnections = searchResult.invalidConnections + + // Cancel outgoing connections. + for connectionID in searchResult.connectionsToCancel { + do { + try await updateConnectionStatus( + connectionID: connectionID, + newStatus: .cancelled, + context: context + ) + } catch { + WireLogger.connectionValidator.warn("Failed to cancel connection with error: \(error)") + allInvalidConnections.removeAll { $0 == connectionID } + } + } + + // Ignore incoming connections. + for connectionID in searchResult.connectionsToIgnore { + do { + try await updateConnectionStatus( + connectionID: connectionID, + newStatus: .ignored, + context: context + ) + } catch { + WireLogger.connectionValidator.warn("Failed to ignore connection with error: \(error)") + allInvalidConnections.removeAll { $0 == connectionID } + } + } + + // Invalidate and unlink the associated conversation. + try await context.perform { [context] in + let connections: [ZMConnection] = allInvalidConnections.compactMap { + guard let connection = try? context.existingObject(with: $0) as? ZMConnection else { + WireLogger.connectionValidator.error("Failed to fetch connection with objectID: \($0)") + return nil + } + return connection + } + + for connection in connections { + guard let existingOneOnOne = connection.to?.oneOnOneConversation else { + continue + } + + // We also check for `invalid` because rejecting the connection may change + // the conversation type to invalid. + guard existingOneOnOne.conversationType.isOne(of: .invalid, .connection) else { + continue + } + + existingOneOnOne.conversationType = .invalid + existingOneOnOne.oneOnOneUser = nil + } + + try context.save() + } + } + + private static func keepConnectionStatuses() -> [ZMConnectionStatus] { + [.accepted, .blocked] + } + + private func updateConnectionStatus( + connectionID: NSManagedObjectID, + newStatus: ZMConnectionStatus, + context: NSManagedObjectContext + ) async throws { + var action = UpdateConnectionAction(connectionID: connectionID, newStatus: newStatus) + try await action.perform(in: context.notificationContext) + } + +} + +private extension WireLogger { + static let connectionValidator = WireLogger(tag: "ConnectionValidator") +} diff --git a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneMigrator.swift b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneMigrator.swift index 8796fff6411..47e14964c12 100644 --- a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneMigrator.swift +++ b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneMigrator.swift @@ -151,21 +151,16 @@ public struct OneOnOneMigrator: OneOnOneMigratorInterface { throw MigrateMLSOneOnOneConversationError.failedToActivateConversation } - // Note on proteus, it's possible to have 2 duplicate 1-1 conversations, so we need to fetch both - // conversations here. - let proteusConversations: [ZMConversation] = fetchAllTeamOneOnOneProteusConversations( - otherUserID: userID, - in: context + // Note on proteus, it's possible to have duplicate 1-1 conversations, so we need to fetch all relevant + // 1-1 conversations here. + let source = OneOnOneSource(context: context) + let proteusConversations = try source.fetchOneOnOnes( + user: otherUser, + types: [.fake, .proteus, .proteusPending] ) - var allProteusConversations = Set(proteusConversations) - if let existingConversation = otherUser.oneOnOneConversation, - existingConversation.messageProtocol == .proteus { - allProteusConversations.insert(existingConversation) - } - - // move local messages from proteus conversations if they exist - for proteusConversation in allProteusConversations { + // Move local messages from all proteus conversations + for proteusConversation in proteusConversations { // Since ZMMessages only have a single conversation connected, // forming this union also removes the relationship to the proteus conversation. mlsConversation.mutableMessages.union(proteusConversation.allMessages) @@ -181,37 +176,6 @@ public struct OneOnOneMigrator: OneOnOneMigratorInterface { } } - func fetchAllTeamOneOnOneProteusConversations( - otherUserID: QualifiedID, - in context: NSManagedObjectContext - ) -> [ZMConversation] { - guard let otherUser = ZMUser.fetch(with: otherUserID, in: context) else { - return [] - } - let selfUser = ZMUser.selfUser(in: context) - guard selfUser.team != nil else { - return [] - } - - let request = NSFetchRequest(entityName: ZMConversation.entityName()) - let teamOneOnOnePredicate = ZMConversation.predicateForTeamOneToOneConversation() - - let sameParticipant = NSPredicate( - format: "ANY %K.user == %@ AND ANY %K.user == %@", - ZMConversationParticipantRolesKey, - otherUser, - ZMConversationParticipantRolesKey, - selfUser - ) - - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - teamOneOnOnePredicate, - sameParticipant - ]) - - return context.fetchOrAssert(request: request) - } - private func createOrJoinMLSConversationIfNeeded( userID: QualifiedID, mlsGroupID: MLSGroupID, diff --git a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolver.swift b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolver.swift index 5dc32fddfea..f6b15c82e14 100644 --- a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolver.swift +++ b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolver.swift @@ -32,6 +32,10 @@ public protocol OneOnOneResolverInterface { } +private extension WireLogger { + static let conversationResolver = WireLogger(tag: "conversationResolver") +} + public final class OneOnOneResolver: OneOnOneResolverInterface { // MARK: - Dependencies @@ -80,25 +84,32 @@ public final class OneOnOneResolver: OneOnOneResolverInterface { let messageProtocol = try await protocolSelector.getProtocolForUser(with: userID, in: context) + let action: OneOnOneConversationResolution switch messageProtocol { case .none where isMLSEnabled: - return await resolveCommonUserProtocolNone(with: userID, in: context) + action = try await resolveCommonUserProtocolNone(with: userID, in: context) case .mls where isMLSEnabled: - return try await resolveCommonUserProtocolMLS(with: userID, in: context) + action = try await resolveCommonUserProtocolMLS(with: userID, in: context) case .proteus: - return await resolveCommonUserProtocolProteus(with: userID, in: context) + action = try await resolveCommonUserProtocolProteus(with: userID, in: context) case .mixed: // This should never happen: // Users can only support proteus and mls protocols. // Mixed protocol is used by conversations to represent // the migration state when migrating from proteus to mls. assertionFailure("users should not have mixed protocol") - return .noAction + action = .noAction default: // if mls not enabled, there is nothing to take action // fixes locked conversations - return .noAction + action = .noAction } + + try await context.perform { + try context.save() + } + + return action } // MARK: Resolve - None @@ -106,25 +117,35 @@ public final class OneOnOneResolver: OneOnOneResolverInterface { private func resolveCommonUserProtocolNone( with userID: QualifiedID, in context: NSManagedObjectContext - ) async -> OneOnOneConversationResolution { + ) async throws -> OneOnOneConversationResolution { WireLogger.conversation.debug("no common protocols found") - await context.perform { - guard - let otherUser = ZMUser.fetch(with: userID, in: context), - let conversation = otherUser.oneOnOneConversation - else { - return + return try await context.perform { + guard let user = ZMUser.fetch(with: userID, in: context) else { throw OneOnOneResolverError.userNotFound } + + let source = OneOnOneSource(context: context) + guard let conversations = try source.fetchOneOnOnesWithCandidate( + user: user, + types: [.mls, .fake, .proteus, .proteusPending] + ) else { + return .noAction } + let best = conversations.candidate + for conversation in conversations.others { + best.mutableMessages.union(conversation.allMessages) + best.needsToBeUpdatedFromBackend = true + } + user.oneOnOneConversation = best + self.makeConversationReadOnly( selfUser: ZMUser.selfUser(in: context), - otherUser: otherUser, - conversation: conversation + otherUser: user, + conversation: best ) - } - return .archivedAsReadOnly + return .archivedAsReadOnly + } } private func makeConversationReadOnly( @@ -193,7 +214,27 @@ public final class OneOnOneResolver: OneOnOneResolverInterface { private func resolveCommonUserProtocolProteus( with userID: QualifiedID, in context: NSManagedObjectContext - ) async -> OneOnOneConversationResolution { + ) async throws -> OneOnOneConversationResolution { + try await context.perform { [context] in + guard let user = ZMUser.fetch(with: userID, in: context) else { + throw OneOnOneResolverError.userNotFound + } + + let source = OneOnOneSource(context: context) + if let conversations = try source.fetchOneOnOnesWithCandidate( + user: user, + types: [.fake, .proteus, .proteusPending] + ) { + let best = conversations.candidate + for conversation in conversations.others { + best.mutableMessages.union(conversation.allMessages) + best.needsToBeUpdatedFromBackend = true + } + + user.oneOnOneConversation = best + } + } + WireLogger.conversation.debug("should resolve to proteus 1-1 conversation") await setReadOnly(to: false, forOneOnOneWithUser: userID, in: context) return .noAction diff --git a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolverError.swift b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolverError.swift index 315bcd7e3a4..48c6983893a 100644 --- a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolverError.swift +++ b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneResolverError.swift @@ -20,4 +20,5 @@ import Foundation enum OneOnOneResolverError: Error { case migratorNotFound + case userNotFound } diff --git a/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneSource.swift b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneSource.swift new file mode 100644 index 00000000000..a1e40e56c71 --- /dev/null +++ b/wire-ios-data-model/Source/MLS/OneOnOne/OneOnOneSource.swift @@ -0,0 +1,185 @@ +// +// 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/. +// + +enum OneOnOneType: Hashable { + case mls + case fake + case proteus + case proteusPending +} + +final class OneOnOneSource { + + struct Result { + let candidate: ZMConversation + let others: [ZMConversation] + } + + private let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + /// Fetches all one-on-one conversations for `user` which match one of the given `types`. + /// + /// The result includes the best candidate conversation. This is determined by the order of the given types and the + /// `primaryKeys` of the conversations. + /// + /// - warning: This method must be called within a `NSManagedObjectContext.perform` block. + + func fetchOneOnOnesWithCandidate(user: ZMUser, types: [OneOnOneType]) throws -> Result? { + let selfUser = ZMUser.selfUser(in: context) + + var candidate: ZMConversation? + var allConversations: [ZMConversation] = [] + for type in types { + let conversations = try sortedConversations(type: type, selfUser: selfUser, otherUser: user) + if candidate == nil { + candidate = conversations.first + } + + allConversations.append(contentsOf: conversations) + } + + guard let candidate else { return nil } + + return Result( + candidate: candidate, + others: allConversations.filter { $0 != candidate } + ) + } + + /// Fetches all one-on-one conversations for `user` which match one of the given `types`. + /// + /// - warning: This method must be called within a `NSManagedObjectContext.perform` block. + + func fetchOneOnOnes(user: ZMUser, types: [OneOnOneType]) throws -> [ZMConversation] { + let selfUser = ZMUser.selfUser(in: context) + let predicate = NSPredicate.any( + of: types.map { Self.predicate(type: $0, selfUser: selfUser, otherUser: user) } + ) + + let fetchRequest = NSFetchRequest(entityName: ZMConversation.entityName()) + fetchRequest.predicate = predicate + + return try context.fetch(fetchRequest) + } + + // MARK: - Private + + private static func predicate(type: OneOnOneType, selfUser: ZMUser, otherUser: ZMUser) -> NSPredicate { + switch type { + case .mls: + .mlsOneOnOne(otherUser: otherUser) + case .fake: + .fakeProteusTeamOneOnOne(selfUser: selfUser, otherUser: otherUser) + case .proteus: + .proteusOneOnOne(otherUser: otherUser) + case .proteusPending: + .pendingProteusOneOnOne(otherUser: otherUser) + } + } + + private func sortedConversations( + type: OneOnOneType, + selfUser: ZMUser, + otherUser: ZMUser + ) throws -> [ZMConversation] { + let fetchRequest = NSFetchRequest(entityName: ZMConversation.entityName()) + fetchRequest.predicate = Self.predicate(type: type, selfUser: selfUser, otherUser: otherUser) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "primaryKey", ascending: true)] + + return try context.fetch(fetchRequest) + } +} + +private extension NSPredicate { + + static func mlsOneOnOne(otherUser: ZMUser) -> NSPredicate { + let isOneOnOne = + NSPredicate(format: "\(ZMConversationConversationTypeKey) == \(ZMConversationType.oneOnOne.rawValue)") + let isMLS = NSPredicate(format: "\(ZMConversation.messageProtocolKey) == \(MessageProtocol.mls.int16Value)") + + return .all(of: [ + isOneOnOne, + isMLS, + hasTwoParticipants, + hasParticipant(user: otherUser) + ]) + } + + static func fakeProteusTeamOneOnOne(selfUser: ZMUser, otherUser: ZMUser) -> NSPredicate { + guard let selfTeam = selfUser.team, selfUser != otherUser else { + return NSPredicate(value: false) + } + + let sameTeam = NSPredicate(format: "team == %@", selfTeam) + let groupConversation = NSPredicate( + format: "%K == %d", + ZMConversationConversationTypeKey, + ZMConversationType.group.rawValue + ) + let noUserDefinedName = NSPredicate(format: "%K == NULL", ZMConversationUserDefinedNameKey) + + return .all(of: [ + sameTeam, + groupConversation, + noUserDefinedName, + hasTwoParticipants, + hasParticipant(user: selfUser), + hasParticipant(user: otherUser) + ]) + } + + static func proteusOneOnOne(otherUser: ZMUser) -> NSPredicate { + let isOneOnOne = + NSPredicate(format: "\(ZMConversationConversationTypeKey) == \(ZMConversationType.oneOnOne.rawValue)") + let isProteus = + NSPredicate(format: "\(ZMConversation.messageProtocolKey) == \(MessageProtocol.proteus.int16Value)") + + return .all(of: [ + isOneOnOne, + isProteus, + hasTwoParticipants, + hasParticipant(user: otherUser) + ]) + } + + static func pendingProteusOneOnOne(otherUser: ZMUser) -> NSPredicate { + let isConnection = + NSPredicate(format: "\(ZMConversationConversationTypeKey) == \(ZMConversationType.connection.rawValue)") + let isProteus = + NSPredicate(format: "\(ZMConversation.messageProtocolKey) == \(MessageProtocol.proteus.int16Value)") + + return .all(of: [ + isConnection, + isProteus, + hasParticipant(user: otherUser) + ]) + } + + // MARK: - Helpers + + private static let hasTwoParticipants = NSPredicate(format: "%K.@count == 2", ZMConversationParticipantRolesKey) + + private static func hasParticipant(user: ZMUser) -> NSPredicate { + NSPredicate(format: "ANY %K.user == %@", ZMConversationParticipantRolesKey, user) + } + +} diff --git a/wire-ios-data-model/Source/Model/Connection/ZMConnection+Actions.swift b/wire-ios-data-model/Source/Model/Connection/ZMConnection+Actions.swift index a1ed627976b..9d718bbdc58 100644 --- a/wire-ios-data-model/Source/Model/Connection/ZMConnection+Actions.swift +++ b/wire-ios-data-model/Source/Model/Connection/ZMConnection+Actions.swift @@ -65,6 +65,11 @@ public struct UpdateConnectionAction: EntityAction { self.connectionID = connection.objectID self.newStatus = newStatus } + + public init(connectionID: NSManagedObjectID, newStatus: ZMConnectionStatus) { + self.connectionID = connectionID + self.newStatus = newStatus + } } public extension ZMUser { diff --git a/wire-ios-data-model/Support/Sources/ModelHelper.swift b/wire-ios-data-model/Support/Sources/ModelHelper.swift index efa2bf2635d..490a2341a19 100644 --- a/wire-ios-data-model/Support/Sources/ModelHelper.swift +++ b/wire-ios-data-model/Support/Sources/ModelHelper.swift @@ -277,7 +277,6 @@ public struct ModelHelper { ) -> (ZMConnection, ZMConversation) { let connection = ZMConnection.insertNewObject(in: context) connection.to = user - connection.status = status connection.message = "Connect to me" connection.lastUpdateDate = .now @@ -287,6 +286,13 @@ public struct ModelHelper { conversation.domain = "local@domain.com" user.oneOnOneConversation = conversation + let selfUser = ZMUser.selfUser(in: context) + ParticipantRole.create(managedObjectContext: context, user: selfUser, conversation: conversation) + ParticipantRole.create(managedObjectContext: context, user: user, conversation: conversation) + + // Setting `status` late as it also updates `conversation.conversationType` to be correct. + connection.status = status + return (connection, conversation) } diff --git a/wire-ios-data-model/Tests/OneOnOne/OneOnOneMigratorTests.swift b/wire-ios-data-model/Tests/OneOnOne/OneOnOneMigratorTests.swift index 1318ef6af75..63bbf5fe653 100644 --- a/wire-ios-data-model/Tests/OneOnOne/OneOnOneMigratorTests.swift +++ b/wire-ios-data-model/Tests/OneOnOne/OneOnOneMigratorTests.swift @@ -378,6 +378,10 @@ final class OneOnOneMigratorTests: XCTestCase { modelHelper.createSelfUser(id: selfUserID.uuid, domain: selfUserID.domain, in: self.syncContext) } + let team = await syncContext.perform { + modelHelper.createTeam(in: self.syncContext) + } + let (_, proteusConversation, mlsConversation) = await createConversations( userID: userID, mlsGroupID: mlsGroupID, @@ -386,7 +390,6 @@ final class OneOnOneMigratorTests: XCTestCase { let duplicateProteusConversation = try await syncContext.perform { let otherUser = try XCTUnwrap(ZMUser.fetch(with: userID.uuid, domain: userID.domain, in: self.syncContext)) - let team = modelHelper.createTeam(in: self.syncContext) modelHelper.addUsers([selfUser, otherUser], to: team, in: self.syncContext) proteusConversation.addParticipantAndUpdateConversationState(user: selfUser) @@ -401,7 +404,6 @@ final class OneOnOneMigratorTests: XCTestCase { let duplicateProteusConversation2 = try await syncContext.perform { let otherUser = try XCTUnwrap(ZMUser.fetch(with: userID.uuid, domain: userID.domain, in: self.syncContext)) - let team = modelHelper.createTeam(in: self.syncContext) modelHelper.addUsers([selfUser, otherUser], to: team, in: self.syncContext) proteusConversation.addParticipantAndUpdateConversationState(user: selfUser) @@ -558,7 +560,6 @@ final class OneOnOneMigratorTests: XCTestCase { ) -> (ZMConnection, ZMConversation) { let connection = ZMConnection.insertNewObject(in: context) connection.to = user - connection.status = status connection.message = "Connect to me" connection.lastUpdateDate = .now @@ -568,6 +569,13 @@ final class OneOnOneMigratorTests: XCTestCase { conversation.domain = "local@domain.com" conversation.oneOnOneUser = connection.to + let selfUser = ZMUser.selfUser(in: context) + ParticipantRole.create(managedObjectContext: context, user: selfUser, conversation: conversation) + ParticipantRole.create(managedObjectContext: context, user: user, conversation: conversation) + + // Setting `status` late as it also updates `conversation.conversationType` to be correct. + connection.status = status + return (connection, conversation) } diff --git a/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj b/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj index e3474817888..ae6979a3486 100644 --- a/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj +++ b/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj @@ -520,10 +520,12 @@ BFFBFD951D59E49D0079773E /* ZMClientMessageTests+Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFBFD941D59E49D0079773E /* ZMClientMessageTests+Deletion.swift */; }; C9D574D32CEF630500012A0E /* TypingUsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D574D22CEF630500012A0E /* TypingUsers.swift */; }; C9D574D52CEF63AC00012A0E /* NSManagedObjectContext+TypingUsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D574D42CEF63AC00012A0E /* NSManagedObjectContext+TypingUsers.swift */; }; + CB181C7E2D3F8D8400A80AB4 /* OneOnOneSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB181C7D2D3F8D7F00A80AB4 /* OneOnOneSource.swift */; }; CB1BF6FC2C8AF6A5001EC670 /* ExpirationReason+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1BF6FB2C8AF6A5001EC670 /* ExpirationReason+Description.swift */; }; CB7979182C747652006FBA58 /* WireTransportSupport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CB7979172C747652006FBA58 /* WireTransportSupport.framework */; }; CB79791B2C7476AC006FBA58 /* TestSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB79791A2C7476AC006FBA58 /* TestSetup.swift */; }; CBD35F2C2D09EBB20080DA37 /* WireCrypto in Frameworks */ = {isa = PBXBuildFile; productRef = CBD35F2B2D09EBB20080DA37 /* WireCrypto */; }; + CBF4F17F2D36935300C9638B /* ConnectionValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF4F17E2D36935300C9638B /* ConnectionValidator.swift */; }; CE4EDC091D6D9A3D002A20AA /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4EDC081D6D9A3D002A20AA /* Reaction.swift */; }; CE4EDC0B1D6DC2D2002A20AA /* ConversationMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4EDC0A1D6DC2D2002A20AA /* ConversationMessage+Reaction.swift */; }; CE58A3FF1CD3B3580037B626 /* ConversationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE58A3FE1CD3B3580037B626 /* ConversationMessage.swift */; }; @@ -1433,9 +1435,11 @@ BFFBFD941D59E49D0079773E /* ZMClientMessageTests+Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ZMClientMessageTests+Deletion.swift"; sourceTree = ""; }; C9D574D22CEF630500012A0E /* TypingUsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingUsers.swift; sourceTree = ""; }; C9D574D42CEF63AC00012A0E /* NSManagedObjectContext+TypingUsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+TypingUsers.swift"; sourceTree = ""; }; + CB181C7D2D3F8D7F00A80AB4 /* OneOnOneSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneSource.swift; sourceTree = ""; }; CB1BF6FB2C8AF6A5001EC670 /* ExpirationReason+Description.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExpirationReason+Description.swift"; sourceTree = ""; }; CB7979172C747652006FBA58 /* WireTransportSupport.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WireTransportSupport.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CB79791A2C7476AC006FBA58 /* TestSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSetup.swift; sourceTree = ""; }; + CBF4F17E2D36935300C9638B /* ConnectionValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionValidator.swift; sourceTree = ""; }; CE4EDC081D6D9A3D002A20AA /* Reaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Reaction.swift; path = Reaction/Reaction.swift; sourceTree = ""; }; CE4EDC0A1D6DC2D2002A20AA /* ConversationMessage+Reaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationMessage+Reaction.swift"; sourceTree = ""; }; CE58A3FE1CD3B3580037B626 /* ConversationMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationMessage.swift; sourceTree = ""; }; @@ -2483,6 +2487,14 @@ path = TypingUsers; sourceTree = ""; }; + CB181F2E2D400B4B00A80AB4 /* EntityValidation */ = { + isa = PBXGroup; + children = ( + CBF4F17E2D36935300C9638B /* ConnectionValidator.swift */, + ); + path = EntityValidation; + sourceTree = ""; + }; CE4EDC071D6D9A04002A20AA /* Reaction */ = { isa = PBXGroup; children = ( @@ -2739,6 +2751,7 @@ EEF09CA52B1DE09800D729A1 /* OneOnOne */ = { isa = PBXGroup; children = ( + CB181C7D2D3F8D7F00A80AB4 /* OneOnOneSource.swift */, EEF09C9F2B1DB0C600D729A1 /* OneOnOneProtocolSelector.swift */, EEF09CA12B1DB3F500D729A1 /* OneOnOneMigrator.swift */, EEF09CA32B1DBC8700D729A1 /* OneOnOneResolver.swift */, @@ -3544,6 +3557,7 @@ 59D058412D1593CF00B687F2 /* Analytics */, E6E504332BC542C5004948E7 /* Authentication */, 069BCC4F2B30945500DF4EC2 /* E2EIdentity */, + CB181F2E2D400B4B00A80AB4 /* EntityValidation */, 638941EC2AF4FD170051ABFD /* UseCases */, 1639A81122608FEB00868AB9 /* Patches */, F9A705C91CAEE01D00C2F5FE /* ManagedObjectContext */, @@ -4363,6 +4377,7 @@ EE2BA00625CB3AA8001EB606 /* InvalidFeatureRemoval.swift in Sources */, EE8B09AD25B86AB10057E85C /* AppLockError.swift in Sources */, 63C2EABD2A93B174008A0AB7 /* AddParticipantAction.swift in Sources */, + CB181C7E2D3F8D8400A80AB4 /* OneOnOneSource.swift in Sources */, 1687ABAE20ECD51E0007C240 /* ZMSearchUser.swift in Sources */, EEF09CA02B1DB0C600D729A1 /* OneOnOneProtocolSelector.swift in Sources */, 163C92AA2630A80400F8DC14 /* NSManagedObjectContext+SelfUser.swift in Sources */, @@ -4492,6 +4507,7 @@ 63D41E7124597E420076826F /* GenericMessage+Flags.swift in Sources */, 5473CC731E14245C00814C03 /* NSManagedObjectContext+Debugging.swift in Sources */, BF46662A1DCB71B0007463FF /* V3Asset.swift in Sources */, + CBF4F17F2D36935300C9638B /* ConnectionValidator.swift in Sources */, F9A706991CAEE01D00C2F5FE /* ZMManagedObject.m in Sources */, F1FDF2F821B152BC00E037A1 /* GenericMessage+Hashing.swift in Sources */, BF491CE41F063EDB0055EE44 /* Account.swift in Sources */, diff --git a/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift b/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift index ae7fede6f3d..1d973abe723 100644 --- a/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift +++ b/wire-ios-request-strategy/Sources/Payloads/Processing/ConnectionPayloadProcessor.swift @@ -61,6 +61,7 @@ final class ConnectionPayloadProcessor { conversation.lastModifiedDate = payload.lastUpdate conversation.addParticipantAndUpdateConversationState(user: connection.to, role: nil) + // The conversation we link here may be wrong and may need to be unset using `ConnectionValidator`. connection.to.oneOnOneConversation = conversation connection.status = payload.status.internalStatus connection.lastUpdateDateInGMT = payload.lastUpdate diff --git a/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategy.swift b/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategy.swift index e9a116b1b8a..16380e6ef49 100644 --- a/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategy.swift +++ b/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategy.swift @@ -243,28 +243,50 @@ extension ConnectionRequestStrategy: ZMEventConsumer { in: context ) - guard payload.connection.status == .accepted, let userID = payload.connection.qualifiedTo else { - return - } - - WaitingGroupTask(context: context) { [self] in - do { - // The client who accepts the connection resolves the conversation immediately. - // Other clients (from self and other user) resolve after a delay to avoid a race condition, - // but also to re-attempt resolution in case of failure. - try await Task.sleep(for: .seconds(oneOnOneResolutionDelay)) + guard let userID = payload.connection.qualifiedTo else { return } + + if payload.connection.status == .accepted { + WaitingGroupTask(context: context) { [self] in + do { + // The client who accepts the connection resolves the conversation immediately. + // Other clients (from self and other user) resolve after a delay to avoid a race condition, + // but also to re-attempt resolution in case of failure. + try await Task.sleep(for: .seconds(oneOnOneResolutionDelay)) + + let resolver = oneOnOneResolver + try await resolver.resolveOneOnOneConversation(with: userID, in: context) + + await context.perform { + _ = context.saveOrRollback() + } + } catch { + WireLogger.conversation.error("Error resolving one-on-one conversation: \(error)") + assertionFailure("Error resolving one-on-one conversation: \(error)") + } + } + } else { + Task { + let userObjectID = await managedObjectContext.perform { [context] in + ZMUser.fetch(with: userID.uuid, domain: userID.domain, in: context)?.objectID + } - let resolver = oneOnOneResolver - try await resolver.resolveOneOnOneConversation(with: userID, in: context) + guard let userObjectID else { + WireLogger.individualToTeamMigration.error("User not found for connection event") + return + } - await context.perform { - _ = context.saveOrRollback() + do { + let connectionValidator = ConnectionValidator(context: context) + try await connectionValidator.cleanUpInvalidConnectionIfNeeded(userObjectID: userObjectID) + try await oneOnOneResolver.resolveOneOnOneConversation(with: userID, in: context) + } catch { + WireLogger.individualToTeamMigration.error( + "failed to clean up invalid connection: \(String(describing: error))" + ) } - } catch { - WireLogger.conversation.error("Error resolving one-on-one conversation: \(error)") - assertionFailure("Error resolving one-on-one conversation: \(error)") } } + } } diff --git a/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategyTests.swift b/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategyTests.swift index b04da293958..812ca10076e 100644 --- a/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategyTests.swift +++ b/wire-ios-request-strategy/Sources/Request Strategies/Connection/ConnectionRequestStrategyTests.swift @@ -47,6 +47,7 @@ final class ConnectionRequestStrategyTests: MessagingTestBase { mockSyncProgress.failCurrentSyncPhasePhase_MockMethod = { _ in } mockOneOnOneResolver = MockOneOnOneResolverInterface() + mockOneOnOneResolver.resolveOneOnOneConversationWithIn_MockValue = .noAction sut = ConnectionRequestStrategy( withManagedObjectContext: syncMOC, diff --git a/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategy.swift b/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategy.swift index 1e91ff83519..3853ccc0e1e 100644 --- a/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategy.swift +++ b/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategy.swift @@ -38,13 +38,17 @@ public class UserProfileRequestStrategy: AbstractRequestStrategy, IdentifierObje let actionSync: EntityActionSync + let oneOnOneResolver: any OneOnOneResolverInterface + public init( managedObjectContext: NSManagedObjectContext, applicationStatus: ApplicationStatus, - syncProgress: SyncProgress + syncProgress: SyncProgress, + oneOnOneResolver: any OneOnOneResolverInterface ) { self.syncProgress = syncProgress + self.oneOnOneResolver = oneOnOneResolver self.userProfileByIDTranscoder = UserProfileByIDTranscoder(context: managedObjectContext) self.userProfileByQualifiedIDTranscoder = UserProfileByQualifiedIDTranscoder(context: managedObjectContext) @@ -208,6 +212,36 @@ extension UserProfileRequestStrategy: ZMEventConsumer { for: user, authoritative: false ) + + if userProfile.updatedKeys.contains(.teamID) { + // The user may have just been added to a team which may + // invalidate existing connections. + let isSelfUser = user.isSelfUser + let userObjectID = user.objectID + let userID = user.qualifiedID + + Task { + do { + let connectionValidator = ConnectionValidator(context: managedObjectContext) + + if isSelfUser { + try await connectionValidator.cleanUpAllInvalidConnections() + try await oneOnOneResolver.resolveAllOneOnOneConversations(in: managedObjectContext) + } else { + try await connectionValidator.cleanUpInvalidConnectionIfNeeded(userObjectID: userObjectID) + if let userID { + try await oneOnOneResolver.resolveOneOnOneConversation( + with: userID, + in: managedObjectContext + ) + } + } + } catch { + WireLogger.individualToTeamMigration + .error("failed to clean up invalid connection: \(String(describing: error))") + } + } + } } func processUserDeletion(_ updateEvent: ZMUpdateEvent) { diff --git a/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategyTests.swift b/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategyTests.swift index ed65f24db67..d78064cab0d 100644 --- a/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategyTests.swift +++ b/wire-ios-request-strategy/Sources/Request Strategies/User/UserProfileRequestStrategyTests.swift @@ -17,6 +17,7 @@ // import Foundation +import WireDataModelSupport import WireRequestStrategySupport import WireTransport import XCTest @@ -27,6 +28,7 @@ class UserProfileRequestStrategyTests: MessagingTestBase { var sut: UserProfileRequestStrategy! var mockApplicationStatus: MockApplicationStatus! var mockSyncProgress: MockSyncProgress! + var mockOneOnOneResolver: MockOneOnOneResolverInterface! var apiVersion: APIVersion! { didSet { @@ -44,10 +46,14 @@ class UserProfileRequestStrategyTests: MessagingTestBase { mockSyncProgress.currentSyncPhase = .done mockSyncProgress.finishCurrentSyncPhasePhase_MockMethod = { _ in } + mockOneOnOneResolver = MockOneOnOneResolverInterface() + mockOneOnOneResolver.resolveOneOnOneConversationWithIn_MockValue = .noAction + sut = UserProfileRequestStrategy( managedObjectContext: syncMOC, applicationStatus: mockApplicationStatus, - syncProgress: mockSyncProgress + syncProgress: mockSyncProgress, + oneOnOneResolver: mockOneOnOneResolver ) apiVersion = .v0 } @@ -56,6 +62,7 @@ class UserProfileRequestStrategyTests: MessagingTestBase { sut = nil mockSyncProgress = nil mockApplicationStatus = nil + mockOneOnOneResolver = nil super.tearDown() } diff --git a/wire-ios-sync-engine/Source/Synchronization/Strategies/TeamMembersDownloadRequestStrategy.swift b/wire-ios-sync-engine/Source/Synchronization/Strategies/TeamMembersDownloadRequestStrategy.swift index b2b43c58251..ab6cfdcee4f 100644 --- a/wire-ios-sync-engine/Source/Synchronization/Strategies/TeamMembersDownloadRequestStrategy.swift +++ b/wire-ios-sync-engine/Source/Synchronization/Strategies/TeamMembersDownloadRequestStrategy.swift @@ -18,12 +18,14 @@ import Foundation -/// Downloads all team members during the slow sync. +/// Downloads all team members during the slow sync and updating when processing events or when manually requested. -public final class TeamMembersDownloadRequestStrategy: AbstractRequestStrategy, ZMSingleRequestTranscoder { +public final class TeamMembersDownloadRequestStrategy: AbstractRequestStrategy, ZMSingleRequestTranscoder, + ZMContextChangeTrackerSource, ZMDownstreamTranscoder { let syncStatus: SyncStatus - var sync: ZMSingleRequestSync! + private var slowSync: ZMSingleRequestSync! + private var downstreamSync: ZMDownstreamObjectSync! public init( withManagedObjectContext managedObjectContext: NSManagedObjectContext, @@ -38,16 +40,30 @@ public final class TeamMembersDownloadRequestStrategy: AbstractRequestStrategy, applicationStatus: applicationStatus ) - configuration = [.allowsRequestsDuringSlowSync] - self.sync = ZMSingleRequestSync(singleRequestTranscoder: self, groupQueue: managedObjectContext) + configuration = [.allowsRequestsWhileOnline, .allowsRequestsDuringSlowSync] + self.downstreamSync = ZMDownstreamObjectSync( + transcoder: self, + entityName: Team.entityName(), + predicateForObjectsToDownload: Team.predicateForObjectsNeedingToBeUpdated, + filter: nil, + managedObjectContext: managedObjectContext + ) + self.slowSync = ZMSingleRequestSync(singleRequestTranscoder: self, groupQueue: managedObjectContext) } public override func nextRequestIfAllowed(for apiVersion: APIVersion) -> ZMTransportRequest? { - guard syncStatus.currentSyncPhase == .fetchingTeamMembers else { return nil } + if syncStatus.currentSyncPhase == .fetchingTeamMembers { + slowSync.readyForNextRequestIfNotBusy() + return slowSync.nextRequest(for: apiVersion) + } else { + return downstreamSync.nextRequest(for: apiVersion) + } + } - sync.readyForNextRequestIfNotBusy() + // MARK: - ZMContextChangeTrackerSource - return sync.nextRequest(for: apiVersion) + public var contextChangeTrackers: [ZMContextChangeTracker] { + [downstreamSync] } // MARK: - ZMSingleRequestTranscoder @@ -91,4 +107,51 @@ public final class TeamMembersDownloadRequestStrategy: AbstractRequestStrategy, func completeSyncPhase() { syncStatus.finishCurrentSyncPhase(phase: .fetchingTeamMembers) } + + // MARK: - ZMDownstreamTranscoder + + public func request( + forFetching object: ZMManagedObject!, + downstreamSync: ZMObjectSync!, + apiVersion: APIVersion + ) -> ZMTransportRequest! { + guard let teamID = (object as? Team)?.remoteIdentifier else { fatalError() } + + let maxResults = 2000 + return ZMTransportRequest( + getFromPath: "/teams/\(teamID.transportString())/members?maxResults=\(maxResults)", + apiVersion: apiVersion.rawValue + ) + } + + public func update(_ object: ZMManagedObject!, with response: ZMTransportResponse!, downstreamSync: ZMObjectSync!) { + guard + response.result == .success, + let team = object as? Team, + let rawData = response.rawData, + let payload = MembershipListPayload(rawData) + else { + return + } + + payload.members.forEach { membershipPayload in + membershipPayload.createOrUpdateMember(team: team, in: managedObjectContext) + } + + team.needsToRedownloadMembers = false + } + + public func delete(_ object: ZMManagedObject!, with response: ZMTransportResponse!, downstreamSync: ZMObjectSync!) { + // No op + } +} + +private extension Team { + + static var predicateForObjectsNeedingToBeUpdated: NSPredicate = .init( + format: "%K == YES AND %K != NULL", + #keyPath(Team.needsToRedownloadMembers), + Team.remoteIdentifierDataKey() + ) + } diff --git a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift index 22eff8e1215..6a647edb4dd 100644 --- a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift +++ b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift @@ -276,7 +276,8 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { UserProfileRequestStrategy( managedObjectContext: syncMOC, applicationStatus: applicationStatusDirectory, - syncProgress: applicationStatusDirectory.syncStatus + syncProgress: applicationStatusDirectory.syncStatus, + oneOnOneResolver: oneOnOneResolver ), ZMLastUpdateEventIDTranscoder( managedObjectContext: syncMOC, diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index 5b8d87f61a3..cb10d1d3f73 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -514,6 +514,16 @@ public final class ZMUserSession: NSObject { let selfUser = ZMUser.selfUser(in: managedObjectContext) selfUser.needsToBeUpdatedFromBackend = true + // Proactively ensure we clean up invalid connection state. + Task { + do { + let connectionValidator = ConnectionValidator(context: syncContext) + try await connectionValidator.cleanUpAllInvalidConnections() + } catch { + WireLogger.session.error("failed to clean up invalid connections: \(String(describing: error))") + } + } + if let clientId = selfUserClient?.safeRemoteIdentifier.safeForLoggingDescription { WireLogger.authentication.addTag(.selfClientId, value: clientId) } diff --git a/wire-ios/Tests/Mocks/MockConverationDirectory.swift b/wire-ios/Tests/Mocks/MockConverationDirectory.swift index 96f8ff83760..6092995110e 100644 --- a/wire-ios/Tests/Mocks/MockConverationDirectory.swift +++ b/wire-ios/Tests/Mocks/MockConverationDirectory.swift @@ -50,4 +50,8 @@ class MockConversationDirectory: ConversationDirectoryType { } } + func refetchAllLists(in managedObjectContext: NSManagedObjectContext) { + // No op + } + } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/ConversationList/ListContent/ConversationListContentController/ViewModel/ConversationListViewModel.swift b/wire-ios/Wire-iOS/Sources/UserInterface/ConversationList/ListContent/ConversationListContentController/ViewModel/ConversationListViewModel.swift index d11146a806e..a4465970798 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/ConversationList/ListContent/ConversationListContentController/ViewModel/ConversationListViewModel.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/ConversationList/ListContent/ConversationListContentController/ViewModel/ConversationListViewModel.swift @@ -16,6 +16,7 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import Combine import DifferenceKit import Foundation import WireDataModel @@ -218,6 +219,7 @@ final class ConversationListViewModel: NSObject { } private var conversationDirectoryToken: Any? + private var tokens = Set() let userSession: UserSession? @@ -232,6 +234,18 @@ final class ConversationListViewModel: NSObject { private func setupObservers() { conversationDirectoryToken = userSession?.conversationDirectory.addObserver(self) + + // TODO: [WPB-15469] Remove casting and see if there is a better way to call `refreshAllLists`. + guard let user = userSession?.selfUser as? ZMUser else { return } + + user.publisher(for: \.teamIdentifier) + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak userSession] _ in + guard let userSession else { return } + + userSession.conversationDirectory.refetchAllLists(in: userSession.contextProvider.viewContext) + }.store(in: &tokens) } func sectionHeaderTitle(sectionIndex: Int) -> String? {