diff --git a/WireAPI/Sources/WireAPI/APIs/FeatureConfigsAPI/FeatureConfigsAPIBuilder.swift b/WireAPI/Sources/WireAPI/APIs/FeatureConfigsAPI/FeatureConfigsAPIBuilder.swift index 4537ffe25b9..2d75ea12c2e 100644 --- a/WireAPI/Sources/WireAPI/APIs/FeatureConfigsAPI/FeatureConfigsAPIBuilder.swift +++ b/WireAPI/Sources/WireAPI/APIs/FeatureConfigsAPI/FeatureConfigsAPIBuilder.swift @@ -26,7 +26,7 @@ public struct FeatureConfigsAPIBuilder { /// Create a new builder. /// - /// - Parameter httpClient: A http client. + /// - Parameter APIService: An api service. public init(apiService: any APIServiceProtocol) { self.apiService = apiService diff --git a/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClients.swift b/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClients.swift new file mode 100644 index 00000000000..1159efbea24 --- /dev/null +++ b/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClients.swift @@ -0,0 +1,84 @@ +// +// 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 WireAPI + +/// Pull self clients from backend and update local state +public struct PullSelfUserClients: PullSelfUserClientsProtocol { + private let userClientsAPI: any UserClientsAPI + private let userClientsLocalStore: any UserClientsLocalStoreProtocol + + init(userClientsAPI: any UserClientsAPI, userClientsLocalStore: any UserClientsLocalStoreProtocol) { + self.userClientsAPI = userClientsAPI + self.userClientsLocalStore = userClientsLocalStore + } + + public func pullSelfClients() async throws { + let remoteSelfClients = try await userClientsAPI.getSelfClients() + + for remoteSelfClient in remoteSelfClients { + let localUserClient = await userClientsLocalStore.fetchOrCreateClient( + id: remoteSelfClient.id + ) + + try await updateClient( + id: remoteSelfClient.id, + from: remoteSelfClient, + isNewClient: localUserClient.isNew + ) + } + + let deletedSelfClientsIDs = await userClientsLocalStore.deletedSelfClients( + newClients: remoteSelfClients.map(\.id) + ) + + for deletedSelfClientID in deletedSelfClientsIDs { + await userClientsLocalStore.deleteClient(id: deletedSelfClientID) + } + } + + func updateClient( + id: String, + from remoteClient: WireAPI.SelfUserClient, + isNewClient: Bool + ) async throws { + await userClientsLocalStore.updateClient( + id: id, + isNewClient: isNewClient, + userClientInfo: remoteClient.toDomainModel() + ) + } + +} + +public extension PullSelfUserClients { + + static func make( + apiService: any APIServiceProtocol, + apiVersion: WireAPI.APIVersion, + context: NSManagedObjectContext + ) -> PullSelfUserClientsProtocol { + let userClientsAPI = UserClientsAPIBuilder(apiService: apiService).makeAPI(for: apiVersion) + + let userLocalStore = UserLocalStore(context: context) + let userClientsLocalStore = UserClientsLocalStore(context: context, userLocalStore: userLocalStore) + + return PullSelfUserClients(userClientsAPI: userClientsAPI, userClientsLocalStore: userClientsLocalStore) + } +} diff --git a/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClientsProtocol.swift b/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClientsProtocol.swift new file mode 100644 index 00000000000..d52e068bf1b --- /dev/null +++ b/WireDomain/Sources/WireDomain/UseCases/PullSelfUserClientsProtocol.swift @@ -0,0 +1,23 @@ +// +// 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/. +// + +// sourcery: AutoMockable +public protocol PullSelfUserClientsProtocol { + + func pullSelfClients() async throws +} diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index 9d5a5b7dbf8..eb46951aa49 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -1100,7 +1100,7 @@ public class MockConversationRepositoryProtocol: ConversationRepositoryProtocol guard let mock = addParticipantsSenderDateConversationIDConversationDomain_MockMethod else { fatalError("no mock for `addParticipantsSenderDateConversationIDConversationDomain`") } - + try await mock(participants, sender, date, conversationID, conversationDomain) } @@ -1507,6 +1507,35 @@ class MockProteusMessageDecryptorProtocol: ProteusMessageDecryptorProtocol { } +public class MockPullSelfUserClientsProtocol: PullSelfUserClientsProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - pullSelfClients + + public var pullSelfClients_Invocations: [Void] = [] + public var pullSelfClients_MockError: Error? + public var pullSelfClients_MockMethod: (() async throws -> Void)? + + public func pullSelfClients() async throws { + pullSelfClients_Invocations.append(()) + + if let error = pullSelfClients_MockError { + throw error + } + + guard let mock = pullSelfClients_MockMethod else { + fatalError("no mock for `pullSelfClients`") + } + + try await mock() + } + +} + public class MockPushSupportedProtocolsUseCaseProtocol: PushSupportedProtocolsUseCaseProtocol { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/UseCases/PullSelfUserClientsTests.swift b/WireDomain/Tests/WireDomainTests/UseCases/PullSelfUserClientsTests.swift new file mode 100644 index 00000000000..e969efa65c2 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/UseCases/PullSelfUserClientsTests.swift @@ -0,0 +1,111 @@ +// +// 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 WireAPISupport +import WireDataModel +import WireDataModelSupport +import WireDomainSupport +import WireTestingPackage +import XCTest +@testable import WireAPI +@testable import WireDomain + +final class PullSelfUserClientsTests: XCTestCase { + + private var sut: PullSelfUserClients! + private var userClientsAPI: MockUserClientsAPI! + private var userClientsLocalStore: MockUserClientsLocalStoreProtocol! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + coreDataStackHelper = CoreDataStackHelper() + modelHelper = ModelHelper() + stack = try await coreDataStackHelper.createStack() + userClientsAPI = MockUserClientsAPI() + userClientsLocalStore = MockUserClientsLocalStoreProtocol() + + sut = PullSelfUserClients( + userClientsAPI: userClientsAPI, + userClientsLocalStore: userClientsLocalStore + ) + } + + override func tearDown() async throws { + stack = nil + userClientsAPI = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + modelHelper = nil + } + + // MARK: - Tests + + func testPullSelfClients_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { + // Mock + + let selfUserClient = await context.perform { [self] in + return modelHelper.createSelfClient( + id: Scaffolding.otherUserClientID, + in: context + ) + } + + userClientsAPI.getSelfClients_MockValue = [ + Scaffolding.selfUserClient + ] + + userClientsLocalStore.fetchOrCreateClientId_MockValue = (selfUserClient, false) + userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_MockMethod = { _, _, _ in } + userClientsLocalStore.deletedSelfClientsNewClients_MockValue = [Scaffolding.userClientID] + userClientsLocalStore.deleteClientId_MockMethod = { _ in } + + // When + + try await sut.pullSelfClients() + + // Then + + XCTAssertEqual(userClientsLocalStore.fetchOrCreateClientId_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.deletedSelfClientsNewClients_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.deleteClientId_Invocations.count, 1) + } + + private enum Scaffolding { + static let userClientID = UUID.mockID1.uuidString + static let otherUserClientID = UUID.mockID2.uuidString + + static let selfUserClient = WireAPI.SelfUserClient( + id: userClientID, + type: .permanent, + activationDate: .now, + label: "test", + model: "test", + deviceClass: .phone, + capabilities: [] + ) + } + +} diff --git a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift index 548f4ece6ed..7ae463d762e 100644 --- a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift +++ b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift @@ -54,6 +54,7 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { proteusProvider: ProteusProviding, mlsService: MLSServiceInterface, coreCryptoProvider: CoreCryptoProviderProtocol, + pullSelfUserClientsFactory: @escaping PullSelfUserClientsFactory, searchUsersCache: SearchUsersCache? ) { @@ -71,6 +72,7 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { proteusProvider: proteusProvider, mlsService: mlsService, coreCryptoProvider: coreCryptoProvider, + pullSelfUserClientsFactory: pullSelfUserClientsFactory, searchUsersCache: searchUsersCache ) @@ -110,6 +112,7 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { proteusProvider: ProteusProviding, mlsService: MLSServiceInterface, coreCryptoProvider: CoreCryptoProviderProtocol, + pullSelfUserClientsFactory: @escaping PullSelfUserClientsFactory, searchUsersCache: SearchUsersCache? ) -> [Any] { let syncMOC = contextProvider.syncContext @@ -382,7 +385,8 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { clientUpdateStatus: applicationStatusDirectory.clientUpdateStatus, resolveOneOnOneConversations: makeResolveOneOnOneConversationsUseCase( context: syncMOC, - resolver: oneOnOneResolver + resolver: oneOnOneResolver, + pullSelfUserClientsFactory: pullSelfUserClientsFactory ) ), ResetSessionRequestStrategy( @@ -417,12 +421,15 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { private static func makeResolveOneOnOneConversationsUseCase( context: NSManagedObjectContext, - resolver: any OneOnOneResolverInterface + resolver: any OneOnOneResolverInterface, + pullSelfUserClientsFactory: @escaping PullSelfUserClientsFactory ) -> any ResolveOneOnOneConversationsUseCaseProtocol { + ResolveOneOnOneConversationsUseCase( context: context, supportedProtocolService: SupportedProtocolsService(context: context), - resolver: resolver + resolver: resolver, + pullSelfUserClientsFactory: pullSelfUserClientsFactory ) } } diff --git a/wire-ios-sync-engine/Source/Use cases/ResolveOneOnOneConversationsUseCase.swift b/wire-ios-sync-engine/Source/Use cases/ResolveOneOnOneConversationsUseCase.swift index 684e360fa52..a27f2409501 100644 --- a/wire-ios-sync-engine/Source/Use cases/ResolveOneOnOneConversationsUseCase.swift +++ b/wire-ios-sync-engine/Source/Use cases/ResolveOneOnOneConversationsUseCase.swift @@ -17,6 +17,8 @@ // import Foundation +import WireDomain +import WireLogging // sourcery: AutoMockable public protocol ResolveOneOnOneConversationsUseCaseProtocol { @@ -25,20 +27,22 @@ public protocol ResolveOneOnOneConversationsUseCaseProtocol { } +typealias PullSelfUserClientsFactory = (NSManagedObjectContext) -> PullSelfUserClientsProtocol + struct ResolveOneOnOneConversationsUseCase: ResolveOneOnOneConversationsUseCaseProtocol { let context: NSManagedObjectContext let supportedProtocolService: any SupportedProtocolsServiceInterface let resolver: any OneOnOneResolverInterface + let pullSelfUserClientsFactory: PullSelfUserClientsFactory func invoke() async throws { - let (oldProtocols, newProtocols) = await context.perform { + let oldProtocols = await context.perform { let selfUser = ZMUser.selfUser(in: context) - let oldProtocols = selfUser.supportedProtocols - let newProtocols = supportedProtocolService.calculateSupportedProtocols() - return (oldProtocols, newProtocols) + return selfUser.supportedProtocols } + let newProtocols = await calculateSupportedProtocols() if oldProtocols != newProtocols { var action = PushSupportedProtocolsAction(supportedProtocols: newProtocols) try await action.perform(in: context.notificationContext) @@ -53,4 +57,15 @@ struct ResolveOneOnOneConversationsUseCase: ResolveOneOnOneConversationsUseCaseP try await resolver.resolveAllOneOnOneConversations(in: context) } } + + private func calculateSupportedProtocols() async -> Set { + // we need the self clients to be up to date before calculating supported protocols + let pullSelfUserClients = pullSelfUserClientsFactory(context) + do { + try await pullSelfUserClients.pullSelfClients() + } catch { + WireLogger.userClient.error("error syncing selfclients: \(error.localizedDescription)") + } + return await context.perform { supportedProtocolService.calculateSupportedProtocols() } + } } diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift index 5b0ba3fdb25..9b0891c7c4f 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession.swift @@ -21,6 +21,7 @@ import Foundation import WireAnalytics import WireAPI import WireDataModel +import WireDomain import WireLogging import WireRequestStrategy import WireSystem @@ -576,6 +577,7 @@ public final class ZMUserSession: NSObject { proteusProvider: proteusProvider, mlsService: mlsService, coreCryptoProvider: coreCryptoProvider, + pullSelfUserClientsFactory: pullSelfUserClientsFactory, searchUsersCache: dependencies.caches.searchUsers ) } @@ -1000,6 +1002,7 @@ extension ZMUserSession: ZMSyncStateDelegate { private func makeResolveOneOnOneConversationsUseCase(context: NSManagedObjectContext) -> any ResolveOneOnOneConversationsUseCaseProtocol { let supportedProtocolService = SupportedProtocolsService(context: context) + let resolver = OneOnOneResolver( migrator: OneOnOneMigrator(mlsService: mlsService), isMLSEnabled: mlsFeature.isEnabled @@ -1008,7 +1011,27 @@ extension ZMUserSession: ZMSyncStateDelegate { return ResolveOneOnOneConversationsUseCase( context: context, supportedProtocolService: supportedProtocolService, - resolver: resolver + resolver: resolver, + pullSelfUserClientsFactory: pullSelfUserClientsFactory + ) + } + + private func pullSelfUserClientsFactory(context: NSManagedObjectContext) -> PullSelfUserClientsProtocol { + guard let apiService = managedObjectContext.performAndWait({ self.apiService }) else { + fatal("cannot initialize ResolveOneOnOneConversationsUseCase") + } + guard let apiVersion = BackendInfo.apiVersion, + let wireAPIVersion = WireAPI.APIVersion(rawValue: UInt(apiVersion.rawValue)) else { + WireLogger.backend.warn("apiVersion not resolved") + + fatal("cannot initialize ResolveOneOnOneConversationsUseCase") + + } + + return PullSelfUserClients.make( + apiService: apiService, + apiVersion: wireAPIVersion, + context: context ) } diff --git a/wire-ios-sync-engine/Tests/Source/Use cases/ResolveOneOnOneConversationsUseCaseTests.swift b/wire-ios-sync-engine/Tests/Source/Use cases/ResolveOneOnOneConversationsUseCaseTests.swift index 4eb6548f5b3..a9e08d74e2c 100644 --- a/wire-ios-sync-engine/Tests/Source/Use cases/ResolveOneOnOneConversationsUseCaseTests.swift +++ b/wire-ios-sync-engine/Tests/Source/Use cases/ResolveOneOnOneConversationsUseCaseTests.swift @@ -17,6 +17,7 @@ // import WireDataModelSupport +import WireDomainSupport import WireSyncEngineSupport import XCTest @testable import WireSyncEngine @@ -28,6 +29,7 @@ final class ResolveOneOnOneConversationsUseCaseTests: XCTestCase { private var sut: ResolveOneOnOneConversationsUseCase! private var mockSupportedProtocolService: MockSupportedProtocolsServiceInterface! private var mockOneOnOneResolver: MockOneOnOneResolverInterface! + private var mockPullSelfUserClients: MockPullSelfUserClientsProtocol! private var stack: CoreDataStack! private let coreDataStackHelper = CoreDataStackHelper() @@ -42,11 +44,16 @@ final class ResolveOneOnOneConversationsUseCaseTests: XCTestCase { stack = try await coreDataStackHelper.createStack() mockSupportedProtocolService = MockSupportedProtocolsServiceInterface() mockOneOnOneResolver = MockOneOnOneResolverInterface() + mockPullSelfUserClients = MockPullSelfUserClientsProtocol() + mockPullSelfUserClients.pullSelfClients_MockMethod = {} sut = ResolveOneOnOneConversationsUseCase( context: syncContext, supportedProtocolService: mockSupportedProtocolService, - resolver: mockOneOnOneResolver + resolver: mockOneOnOneResolver, + pullSelfUserClientsFactory: { _ in + self.mockPullSelfUserClients + } ) } @@ -56,6 +63,7 @@ final class ResolveOneOnOneConversationsUseCaseTests: XCTestCase { stack = nil mockSupportedProtocolService = nil mockOneOnOneResolver = nil + mockPullSelfUserClients = nil sut = nil try coreDataStackHelper.cleanupDirectory() try await super.tearDown() @@ -63,6 +71,23 @@ final class ResolveOneOnOneConversationsUseCaseTests: XCTestCase { // MARK: - Unit Tests + func test_invoke_Calls_PullSelfClients() async throws { + // GIVEN + await syncContext.perform { [self] in + let selfUser = ZMUser.selfUser(in: syncContext) + selfUser.supportedProtocols = [.mls] + mockSupportedProtocolService.calculateSupportedProtocols_MockValue = [.mls] + } + mockOneOnOneResolver.resolveAllOneOnOneConversationsIn_MockMethod = { _ in } + + // WHEN + try await sut.invoke() + + // THEN + XCTAssertEqual(mockPullSelfUserClients.pullSelfClients_Invocations.count, 1) + XCTAssertEqual(mockOneOnOneResolver.resolveAllOneOnOneConversationsIn_Invocations.count, 1) + } + func test_SupportedProtocolsRemainProteusOnly() async throws { // GIVEN await syncContext.perform { [self] in