From c40a1709090b7535dab5ea8367a266bc9128f01f Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 29 Oct 2025 16:32:14 +0200 Subject: [PATCH 01/17] Use EventBatcher from chat --- Package.swift | 2 +- Sources/StreamChat/Utils/EventBatcher.swift | 81 ------------------- .../WebSocketClient/Events/Event.swift | 3 - .../WebSocketClient/WebSocketClient.swift | 2 +- .../Extensions/StreamCore+Extensions.swift | 4 - StreamChat.xcodeproj/project.pbxproj | 8 +- 6 files changed, 3 insertions(+), 97 deletions(-) delete mode 100644 Sources/StreamChat/Utils/EventBatcher.swift diff --git a/Package.swift b/Package.swift index e24558482dd..00f8e62ee88 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", exact: "1.0.0"), - .package(url: "https://github.com/GetStream/stream-core-swift.git", branch: "chat-json-encoding-decoding") + .package(url: "https://github.com/GetStream/stream-core-swift.git", branch: "chat-web-socket-client") ], targets: [ .target( diff --git a/Sources/StreamChat/Utils/EventBatcher.swift b/Sources/StreamChat/Utils/EventBatcher.swift deleted file mode 100644 index 51d03095104..00000000000 --- a/Sources/StreamChat/Utils/EventBatcher.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type that does events batching. -protocol EventBatcher: Sendable { - typealias Batch = [Event] - typealias BatchHandler = (_ batch: Batch, _ completion: @escaping @Sendable () -> Void) -> Void - - /// The current batch of events. - var currentBatch: Batch { get } - - /// Creates new batch processor. - init(period: TimeInterval, timerType: TimerScheduling.Type, handler: @escaping BatchHandler) - - /// Adds the item to the current batch of events. If it's the first event also schedules batch processing - /// that will happen when `period` has passed. - /// - /// - Parameter event: The event to add to the current batch. - func append(_ event: Event) - - /// Ignores `period` and passes the current batch of events to handler as soon as possible. - func processImmediately(completion: @escaping @Sendable () -> Void) -} - -extension Batcher: EventBatcher where Item == Event {} -extension Batcher: Sendable where Item == Event {} - -final class Batcher { - /// The batching period. If the item is added sonner then `period` has passed after the first item they will get into the same batch. - private let period: TimeInterval - /// The time used to create timers. - private let timerType: TimerScheduling.Type - /// The timer that calls `processor` when fired. - private nonisolated(unsafe) var batchProcessingTimer: TimerControl? - /// The closure which processes the batch. - private nonisolated(unsafe) let handler: (_ batch: [Item], _ completion: @escaping @Sendable () -> Void) -> Void - /// The serial queue where item appends and batch processing is happening on. - private let queue = DispatchQueue(label: "io.getstream.Batch.\(Item.self)") - /// The current batch of items. - private(set) nonisolated(unsafe) var currentBatch: [Item] = [] - - init( - period: TimeInterval, - timerType: TimerScheduling.Type = DefaultTimer.self, - handler: @escaping (_ batch: [Item], _ completion: @escaping @Sendable () -> Void) -> Void - ) { - self.period = max(period, 0) - self.timerType = timerType - self.handler = handler - } - - func append(_ item: Item) { - timerType.schedule(timeInterval: 0, queue: queue) { [weak self] in - self?.currentBatch.append(item) - - guard let self = self, self.batchProcessingTimer == nil else { return } - - self.batchProcessingTimer = self.timerType.schedule( - timeInterval: self.period, - queue: self.queue, - onFire: { self.process() } - ) - } - } - - func processImmediately(completion: @escaping @Sendable () -> Void) { - timerType.schedule(timeInterval: 0, queue: queue) { [weak self] in - self?.process(completion: completion) - } - } - - private func process(completion: (@Sendable () -> Void)? = nil) { - handler(currentBatch) { completion?() } - currentBatch.removeAll() - batchProcessingTimer?.cancel() - batchProcessingTimer = nil - } -} diff --git a/Sources/StreamChat/WebSocketClient/Events/Event.swift b/Sources/StreamChat/WebSocketClient/Events/Event.swift index 5ceceb2a191..2a9b463850b 100644 --- a/Sources/StreamChat/WebSocketClient/Events/Event.swift +++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift @@ -4,9 +4,6 @@ import Foundation -/// An `Event` object representing an event in the chat system. -public protocol Event: Sendable {} - public extension Event { var name: String { String(describing: Self.self).replacingOccurrences(of: "DTO", with: "") diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift index 679b4b54404..d21159d5b3b 100644 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift @@ -185,7 +185,7 @@ extension WebSocketClient { } var eventBatcherBuilder: ( - _ handler: @escaping ([Event], @escaping @Sendable () -> Void) -> Void + _ handler: @escaping @Sendable ([Event], @escaping @Sendable () -> Void) -> Void ) -> EventBatcher = { Batcher(period: 0.5, handler: $0) } diff --git a/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift index 9e6253bd553..cddf4b1976e 100644 --- a/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift +++ b/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift @@ -3,7 +3,3 @@ // @_exported import StreamCore - -// TODO: Remove after StreamCore migration -import StreamChat -public typealias Event = StreamChat.Event diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 286facb83ef..a0912ceacc5 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -923,7 +923,6 @@ 84C85B452BF2B2D1008A7AA5 /* Poll+Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C85B442BF2B2D1008A7AA5 /* Poll+Unique.swift */; }; 84C85B472BF2B5D0008A7AA5 /* PollController+SwiftUI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C85B462BF2B5D0008A7AA5 /* PollController+SwiftUI_Tests.swift */; }; 84CC56EC267B3F6B00DF2784 /* AnyAttachmentPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CC56EA267B3D5900DF2784 /* AnyAttachmentPayload_Tests.swift */; }; - 84CF9C73274D473D00BCDE2D /* EventBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */; }; 84D5BC59277B188E00A65C75 /* PinnedMessagesPagination_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D5BC58277B188E00A65C75 /* PinnedMessagesPagination_Tests.swift */; }; 84D5BC5B277B18AF00A65C75 /* PinnedMessagesQuery_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D5BC5A277B18AF00A65C75 /* PinnedMessagesQuery_Tests.swift */; }; 84D5BC6F277B619D00A65C75 /* PinnedMessagesSortingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D5BC6D277B619200A65C75 /* PinnedMessagesSortingKey.swift */; }; @@ -1714,7 +1713,6 @@ AD78568F298B273900C2FEAD /* ChatClient+ChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD78568E298B273900C2FEAD /* ChatClient+ChannelController.swift */; }; AD785690298B273900C2FEAD /* ChatClient+ChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD78568E298B273900C2FEAD /* ChatClient+ChannelController.swift */; }; AD78F9EE28EC718700BC0FCE /* URL+EnrichedURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36C39F42860680A0004EB7E /* URL+EnrichedURL.swift */; }; - AD78F9EF28EC718D00BC0FCE /* EventBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */; }; AD78F9F028EC719200BC0FCE /* ChannelTruncateRequestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B0CFA127BBF52600F352F9 /* ChannelTruncateRequestPayload.swift */; }; AD78F9F128EC724300BC0FCE /* UnknownUserEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30C3F1F276B428F00DA5968 /* UnknownUserEvent.swift */; }; AD78F9F428EC72D700BC0FCE /* UIScrollView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849980F0277246DB00ABA58B /* UIScrollView+Extensions.swift */; }; @@ -3880,7 +3878,6 @@ 84C85B442BF2B2D1008A7AA5 /* Poll+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Poll+Unique.swift"; sourceTree = ""; }; 84C85B462BF2B5D0008A7AA5 /* PollController+SwiftUI_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollController+SwiftUI_Tests.swift"; sourceTree = ""; }; 84CC56EA267B3D5900DF2784 /* AnyAttachmentPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyAttachmentPayload_Tests.swift; sourceTree = ""; }; - 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBatcher.swift; sourceTree = ""; }; 84D5BC58277B188E00A65C75 /* PinnedMessagesPagination_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedMessagesPagination_Tests.swift; sourceTree = ""; }; 84D5BC5A277B18AF00A65C75 /* PinnedMessagesQuery_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedMessagesQuery_Tests.swift; sourceTree = ""; }; 84D5BC6D277B619200A65C75 /* PinnedMessagesSortingKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessagesSortingKey.swift; sourceTree = ""; }; @@ -5960,7 +5957,6 @@ ADFCA5B82D1378E2000F515F /* Throttler.swift */, 40789D3B29F6AD9C0018C2BB /* Debouncer.swift */, 88EA9AD725470F6A007EE76B /* Dictionary+Extensions.swift */, - 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */, C173538D27D9F804008AC412 /* KeyedDecodingContainer+Array.swift */, 4FE56B8C2D5DFE3A00589F9A /* MarkdownParser.swift */, 79CD959124F9380B00E87377 /* MulticastDelegate.swift */, @@ -11743,7 +11739,6 @@ 79280F4F2485308100CDEB89 /* DataController.swift in Sources */, ADC40C3426E294EB005B616C /* MessageSearchController+Combine.swift in Sources */, F6ED5F7825027907005D7327 /* MissingEventsRequestBody.swift in Sources */, - 84CF9C73274D473D00BCDE2D /* EventBatcher.swift in Sources */, 40789D1B29F6AC500018C2BB /* AudioPlaybackState.swift in Sources */, C1E8AD57278C8A6E0041B775 /* SyncRepository.swift in Sources */, 84B7383D2BE8C13A00EC66EC /* PollController+SwiftUI.swift in Sources */, @@ -13036,7 +13031,6 @@ C121E8F1274544B200023E4C /* MultipartFormData.swift in Sources */, AD545E672D53C271008FD399 /* DraftMessagesRepository.swift in Sources */, C121E8F2274544B200023E4C /* SystemEnvironment+Version.swift in Sources */, - AD78F9EF28EC718D00BC0FCE /* EventBatcher.swift in Sources */, 4F8E53162B7F58BE008C0F9F /* Chat.swift in Sources */, 404296DB2A0112D00089126D /* AudioQueuePlayer.swift in Sources */, 40A458EE2A03AC7C00C198F7 /* AVAsset+TotalAudioSamples.swift in Sources */, @@ -15803,7 +15797,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GetStream/stream-core-swift"; requirement = { - branch = "chat-json-encoding-decoding"; + branch = "chat-web-socket-client"; kind = branch; }; }; From 5616360428d8c46c87cbf14f0566c9e726d9ea2b Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 29 Oct 2025 16:52:28 +0200 Subject: [PATCH 02/17] Replace RetryStrategy with StreamCore --- .../WebSocketClient/RetryStrategy.swift | 59 -------- StreamChat.xcodeproj/project.pbxproj | 10 -- .../WebSocketClient/RetryStrategy_Tests.swift | 127 ------------------ 3 files changed, 196 deletions(-) delete mode 100644 Sources/StreamChat/WebSocketClient/RetryStrategy.swift delete mode 100644 Tests/StreamChatTests/WebSocketClient/RetryStrategy_Tests.swift diff --git a/Sources/StreamChat/WebSocketClient/RetryStrategy.swift b/Sources/StreamChat/WebSocketClient/RetryStrategy.swift deleted file mode 100644 index ac9ba454315..00000000000 --- a/Sources/StreamChat/WebSocketClient/RetryStrategy.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type encapsulating the logic of computing delays for the failed actions that needs to be retried. -protocol RetryStrategy: Sendable { - /// Returns the # of consecutively failed retries. - var consecutiveFailuresCount: Int { get } - - /// Increments the # of consecutively failed retries making the next delay longer. - mutating func incrementConsecutiveFailures() - - /// Resets the # of consecutively failed retries making the next delay be the shortest one. - mutating func resetConsecutiveFailures() - - /// Calculates and returns the delay for the next retry. - /// - /// Consecutive calls after the same # of failures may return different delays. This randomization is done to - /// make the retry intervals slightly different for different callers to avoid putting the backend down by - /// making all the retries at the same time. - /// - /// - Returns: The delay for the next retry. - func nextRetryDelay() -> TimeInterval -} - -extension RetryStrategy { - /// Returns the delay and then increments # of consecutively failed retries. - /// - /// - Returns: The delay for the next retry. - mutating func getDelayAfterTheFailure() -> TimeInterval { - defer { incrementConsecutiveFailures() } - - return nextRetryDelay() - } -} - -/// The default implementation of `RetryStrategy` with exponentially growing delays. -struct DefaultRetryStrategy: RetryStrategy { - static let maximumReconnectionDelay: TimeInterval = 25 - - @Atomic private(set) var consecutiveFailuresCount = 0 - - mutating func incrementConsecutiveFailures() { - _consecutiveFailuresCount.mutate { $0 += 1 } - } - - mutating func resetConsecutiveFailures() { - _consecutiveFailuresCount.mutate { $0 = 0 } - } - - func nextRetryDelay() -> TimeInterval { - let count = consecutiveFailuresCount - let maxDelay: TimeInterval = min(0.5 + Double(count * 2), Self.maximumReconnectionDelay) - let minDelay: TimeInterval = min(max(0.25, (Double(count) - 1) * 2), Self.maximumReconnectionDelay) - return TimeInterval.random(in: minDelay...maxDelay) - } -} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index a0912ceacc5..bd5249ab7c4 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -629,7 +629,6 @@ 7990503224CEEAA600689CDC /* MessageDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7990503124CEEAA600689CDC /* MessageDTO_Tests.swift */; }; 7991D83D24F7E93900D21BA3 /* ChannelListController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7991D83C24F7E93900D21BA3 /* ChannelListController+SwiftUI.swift */; }; 79983C8126663436000995F6 /* ChatMessageVideoAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */; }; - 799BE2EA248A8C9D00DAC8A0 /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799BE2E9248A8C9D00DAC8A0 /* RetryStrategy.swift */; }; 799C941F247D2F80001F1104 /* Sources.h in Headers */ = {isa = PBXBuildFile; fileRef = 799C941D247D2F80001F1104 /* Sources.h */; settings = {ATTRIBUTES = (Public, ); }; }; 799C9438247D2FB9001F1104 /* ChatClientConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9428247D2FB9001F1104 /* ChatClientConfig.swift */; }; 799C9439247D2FB9001F1104 /* ChannelDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C942A247D2FB9001F1104 /* ChannelDTO.swift */; }; @@ -1282,7 +1281,6 @@ A3813B4C2825C8030076E838 /* CustomChatMessageListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3813B4B2825C8030076E838 /* CustomChatMessageListRouter.swift */; }; A3813B4E2825C8A30076E838 /* ThreadVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3813B4D2825C8A30076E838 /* ThreadVC.swift */; }; A382131E2805C8AC0068D30E /* TestsEnvironmentSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A382131D2805C8AC0068D30E /* TestsEnvironmentSetup.swift */; }; - A3960E0B27DA587B003AB2B0 /* RetryStrategy_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3960E0A27DA587B003AB2B0 /* RetryStrategy_Tests.swift */; }; A3960E0D27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */; }; A39A8AE7263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A8AE6263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift */; }; A39B040B27F196F200D6B18A /* StreamChatUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39B040A27F196F200D6B18A /* StreamChatUITests.swift */; }; @@ -2105,7 +2103,6 @@ C121E825274544AD00023E4C /* BackgroundTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */; }; C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9444247D3DD2001F1104 /* WebSocketClient.swift */; }; C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A618E4424D19D510003D83C /* WebSocketPingController.swift */; }; - C121E828274544AD00023E4C /* RetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799BE2E9248A8C9D00DAC8A0 /* RetryStrategy.swift */; }; C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */; }; C121E82A274544AD00023E4C /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */; }; C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8933D2025FFAB400054BBFF /* APIPathConvertible.swift */; }; @@ -3612,7 +3609,6 @@ 7991D83E24F8F1BF00D21BA3 /* ChatClient_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClient_Mock.swift; sourceTree = ""; }; 79983C80266633C2000995F6 /* ChatMessageVideoAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageVideoAttachment.swift; sourceTree = ""; }; 799B5DC4253081C900C108FB /* DevicePayloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePayloads.swift; sourceTree = ""; }; - 799BE2E9248A8C9D00DAC8A0 /* RetryStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategy.swift; sourceTree = ""; }; 799C941B247D2F80001F1104 /* StreamChat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StreamChat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 799C941D247D2F80001F1104 /* Sources.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Sources.h; sourceTree = ""; }; 799C941E247D2F80001F1104 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -4188,7 +4184,6 @@ A3813B4B2825C8030076E838 /* CustomChatMessageListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomChatMessageListRouter.swift; sourceTree = ""; }; A3813B4D2825C8A30076E838 /* ThreadVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadVC.swift; sourceTree = ""; }; A382131D2805C8AC0068D30E /* TestsEnvironmentSetup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestsEnvironmentSetup.swift; sourceTree = ""; }; - A3960E0A27DA587B003AB2B0 /* RetryStrategy_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RetryStrategy_Tests.swift; sourceTree = ""; }; A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionRecoveryHandler_Tests.swift; sourceTree = ""; }; A396B752260CCE7400D8D15B /* TitleContainerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleContainerView_Tests.swift; sourceTree = ""; }; A39A8AE6263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageLayoutOptionsResolver.swift; sourceTree = ""; }; @@ -6177,7 +6172,6 @@ 799C9444247D3DD2001F1104 /* WebSocketClient.swift */, 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */, 8A618E4424D19D510003D83C /* WebSocketPingController.swift */, - 799BE2E9248A8C9D00DAC8A0 /* RetryStrategy.swift */, 79280F76248917EB00CDEB89 /* Engine */, 796610B7248E64EC00761629 /* EventMiddlewares */, 79280F402484F4DD00CDEB89 /* Events */, @@ -7280,7 +7274,6 @@ children = ( DB05FC1025D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift */, 797EEA4924FFC37600C81203 /* ConnectionStatus_Tests.swift */, - A3960E0A27DA587B003AB2B0 /* RetryStrategy_Tests.swift */, 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */, 430156F126B4523A0006E7EA /* WebSocketConnectPayload_Tests.swift */, 8A08C6A524D437DF00DEF995 /* WebSocketPingController_Tests.swift */, @@ -11842,7 +11835,6 @@ 792A4F3F247FFDE700EAF71D /* Codable+Extensions.swift in Sources */, 8819DFCF2525F3C600FD1A50 /* UserUpdater.swift in Sources */, AD17E1232E01CAAF001AF308 /* NewLocationInfo.swift in Sources */, - 799BE2EA248A8C9D00DAC8A0 /* RetryStrategy.swift in Sources */, DA84070C25250581005A0F62 /* UserListPayload.swift in Sources */, 79877A272498E50D00015F8B /* MemberModelDTO.swift in Sources */, ADB951B2291C3CE900800554 /* AnyAttachmentUpdater.swift in Sources */, @@ -12118,7 +12110,6 @@ buildActionMask = 2147483647; files = ( DA49714E2549C28000AC68C2 /* AttachmentDTO_Tests.swift in Sources */, - A3960E0B27DA587B003AB2B0 /* RetryStrategy_Tests.swift in Sources */, AD0CC0282BDBF9DD005E2C66 /* ReactionListUpdater_Tests.swift in Sources */, 88F6DF94252C8866009A8AF0 /* ChannelMemberUpdater_Tests.swift in Sources */, 84EB4E76276A012900E47E73 /* ClientError_Tests.swift in Sources */, @@ -12668,7 +12659,6 @@ C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */, C135A1CC28F45F6B0058EFB6 /* AuthenticationRepository.swift in Sources */, C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */, - C121E828274544AD00023E4C /* RetryStrategy.swift in Sources */, C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */, 4F8E53172B7F58C1008C0F9F /* ChatClient+Factory.swift in Sources */, C121E82A274544AD00023E4C /* ConnectionStatus.swift in Sources */, diff --git a/Tests/StreamChatTests/WebSocketClient/RetryStrategy_Tests.swift b/Tests/StreamChatTests/WebSocketClient/RetryStrategy_Tests.swift deleted file mode 100644 index 6b880eddf76..00000000000 --- a/Tests/StreamChatTests/WebSocketClient/RetryStrategy_Tests.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class RetryStrategy_Tests: XCTestCase { - var strategy: DefaultRetryStrategy! - - override func setUp() { - super.setUp() - - strategy = DefaultRetryStrategy() - } - - override func tearDown() { - strategy = nil - - super.tearDown() - } - - func test_consecutiveFailures_isZeroInitially() { - XCTAssertEqual(strategy.consecutiveFailuresCount, 0) - } - - func test_incrementConsecutiveFailures_makesDelaysLonger() { - // Declare array for delays - var delays: [TimeInterval] = [] - - for _ in 0..<10 { - // Ask for reconection delay - delays.append(strategy.nextRetryDelay()) - - // Simulate failed retry - strategy.incrementConsecutiveFailures() - } - - // Check the delays are increasing - XCTAssert(delays.first! < delays.last!) - } - - func test_incrementConsecutiveFailures_incrementsConsecutiveFailures() { - // Cache current # of consecutive failures - var prevValue = strategy.consecutiveFailuresCount - - for _ in 0..<10 { - // Simulate failed retry - strategy.incrementConsecutiveFailures() - - // Assert # of consecutive failures is incremeneted - XCTAssertEqual(strategy.consecutiveFailuresCount, prevValue + 1) - - // Update # of consecutive failures - prevValue = strategy.consecutiveFailuresCount - } - } - - func test_resetConsecutiveFailures_setsConsecutiveFailuresToZero() { - // Simulate some # of failed retries - for _ in 0..() - - // Denerate some delays - for _ in 0..<10 { - delays.insert(strategy.nextRetryDelay()) - } - - // Assert delays are not the same - XCTAssertTrue(delays.count > 1) - } - - func test_getDelayAfterTheFailure_returnsDelaysAndIncrementsConsecutiveFailures() { - // Create mock strategy - struct MockStrategy: RetryStrategy { - let consecutiveFailuresCount: Int = 0 - let incrementConsecutiveFailuresClosure: @Sendable () -> Void - let nextRetryDelayClosure: @Sendable () -> Void - - func resetConsecutiveFailures() {} - - func incrementConsecutiveFailures() { - incrementConsecutiveFailuresClosure() - } - - func nextRetryDelay() -> TimeInterval { - nextRetryDelayClosure() - return 0 - } - } - - // Create mock strategy instance and catch `incrementConsecutiveFailures/nextRetryDelay` calls - nonisolated(unsafe) var incrementConsecutiveFailuresCalled = false - nonisolated(unsafe) var nextRetryDelayClosure = false - - var strategy = MockStrategy( - incrementConsecutiveFailuresClosure: { - incrementConsecutiveFailuresCalled = true - }, - nextRetryDelayClosure: { - // Assert failured # is incremented after the delay is computed - XCTAssertFalse(incrementConsecutiveFailuresCalled) - nextRetryDelayClosure = true - } - ) - - // Call `getDelayAfterTheFailure` - _ = strategy.getDelayAfterTheFailure() - - // Assert both methods are invoked - XCTAssertTrue(incrementConsecutiveFailuresCalled) - XCTAssertTrue(nextRetryDelayClosure) - } -} From 4fc353a62acadebe33e2f41d8852a7d12a035209 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 09:27:13 +0200 Subject: [PATCH 03/17] Use InternetConnection from StreamCore --- .../InternetConnection.swift | 201 ------------------ StreamChat.xcodeproj/project.pbxproj | 10 - .../InternetConnectionMonitor_Mock.swift | 4 +- .../MockNetwork/InternetConnection_Mock.swift | 2 +- .../InternetConnection_Tests.swift | 97 --------- 5 files changed, 3 insertions(+), 311 deletions(-) delete mode 100644 Sources/StreamChat/Utils/InternetConnection/InternetConnection.swift delete mode 100644 Tests/StreamChatTests/Utils/InternetConnection/InternetConnection_Tests.swift diff --git a/Sources/StreamChat/Utils/InternetConnection/InternetConnection.swift b/Sources/StreamChat/Utils/InternetConnection/InternetConnection.swift deleted file mode 100644 index 37d7ee3c995..00000000000 --- a/Sources/StreamChat/Utils/InternetConnection/InternetConnection.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import Network - -extension Notification.Name { - /// Posted when any the Internet connection update is detected (including quality updates). - static let internetConnectionStatusDidChange = Self("io.getstream.StreamChat.internetConnectionStatus") - - /// Posted only when the Internet connection availability is changed (excluding quality updates). - static let internetConnectionAvailabilityDidChange = Self("io.getstream.StreamChat.internetConnectionAvailability") -} - -extension Notification { - static let internetConnectionStatusUserInfoKey = "internetConnectionStatus" - - var internetConnectionStatus: InternetConnection.Status? { - userInfo?[Self.internetConnectionStatusUserInfoKey] as? InternetConnection.Status - } -} - -/// An Internet Connection monitor. -class InternetConnection: @unchecked Sendable { - /// The current Internet connection status. - private(set) var status: InternetConnection.Status { - didSet { - guard oldValue != status else { return } - - log.info("Internet Connection: \(status)") - - postNotification(.internetConnectionStatusDidChange, with: status) - - guard oldValue.isAvailable != status.isAvailable else { return } - - postNotification(.internetConnectionAvailabilityDidChange, with: status) - } - } - - /// The notification center that posts notifications when connection state changes.. - let notificationCenter: NotificationCenter - - /// A specific Internet connection monitor. - private var monitor: InternetConnectionMonitor - - /// Creates a `InternetConnection` with a given monitor. - /// - Parameter monitor: an Internet connection monitor. Use nil for a default `InternetConnectionMonitor`. - init( - notificationCenter: NotificationCenter = .default, - monitor: InternetConnectionMonitor - ) { - self.notificationCenter = notificationCenter - self.monitor = monitor - - status = monitor.status - monitor.delegate = self - monitor.start() - } - - deinit { - monitor.stop() - } -} - -extension InternetConnection: InternetConnectionDelegate { - func internetConnectionStatusDidChange(status: Status) { - self.status = status - } -} - -private extension InternetConnection { - func postNotification(_ name: Notification.Name, with status: Status) { - notificationCenter.post( - name: name, - object: self, - userInfo: [Notification.internetConnectionStatusUserInfoKey: status] - ) - } -} - -// MARK: - Internet Connection Monitors - -/// A delegate to receive Internet connection events. -protocol InternetConnectionDelegate: AnyObject { - /// Calls when the Internet connection status did change. - /// - Parameter status: an Internet connection status. - func internetConnectionStatusDidChange(status: InternetConnection.Status) -} - -/// A protocol for Internet connection monitors. -protocol InternetConnectionMonitor: AnyObject, Sendable { - /// A delegate for receiving Internet connection events. - var delegate: InternetConnectionDelegate? { get set } - - /// The current status of Internet connection. - var status: InternetConnection.Status { get } - - /// Start Internet connection monitoring. - func start() - /// Stop Internet connection monitoring. - func stop() -} - -// MARK: Internet Connection Subtypes - -extension InternetConnection { - /// The Internet connectivity status. - enum Status: Equatable { - /// Notification of an Internet connection has not begun. - case unknown - - /// The Internet is available with a specific `Quality` level. - case available(Quality) - - /// The Internet is unavailable. - case unavailable - } - - /// The Internet connectivity status quality. - enum Quality: Equatable { - /// The Internet connection is great (like Wi-Fi). - case great - - /// Internet connection uses an interface that is considered expensive, such as Cellular or a Personal Hotspot. - case expensive - - /// Internet connection uses Low Data Mode. - /// Recommendations for Low Data Mode: don't autoplay video, music (high-quality) or gifs (big files). - case constrained - } -} - -extension InternetConnection.Status { - /// Returns `true` if the internet connection is available, ignoring the quality of the connection. - var isAvailable: Bool { - if case .available = self { - return true - } else { - return false - } - } -} - -// MARK: - Internet Connection Monitor - -extension InternetConnection { - final class Monitor: InternetConnectionMonitor, @unchecked Sendable { - private var monitor: NWPathMonitor? - private let queue = DispatchQueue(label: "io.getstream.internet-monitor") - - weak var delegate: InternetConnectionDelegate? - - var status: InternetConnection.Status { - if let path = monitor?.currentPath { - return Self.status(from: path) - } - - return .unknown - } - - func start() { - guard monitor == nil else { return } - - monitor = createMonitor() - monitor?.start(queue: queue) - } - - func stop() { - monitor?.cancel() - monitor = nil - } - - private func createMonitor() -> NWPathMonitor { - let monitor = NWPathMonitor() - - // We should be able to do `[weak self]` here, but it seems `NWPathMonitor` sometimes calls the handler - // event after `cancel()` has been called on it. - monitor.pathUpdateHandler = { [weak self] path in - log.info("Internet Connection info: \(path.debugDescription)") - self?.delegate?.internetConnectionStatusDidChange(status: Self.status(from: path)) - } - return monitor - } - - private static func status(from path: NWPath) -> InternetConnection.Status { - guard path.status == .satisfied else { - return .unavailable - } - - let quality: InternetConnection.Quality - quality = path.isConstrained ? .constrained : (path.isExpensive ? .expensive : .great) - - return .available(quality) - } - - deinit { - stop() - } - } -} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index bd5249ab7c4..5f35d6e117f 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1090,8 +1090,6 @@ 8AC9CBD624C73689006E236C /* NotificationEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9CBD524C73689006E236C /* NotificationEvents.swift */; }; 8AC9CBE424C74ECB006E236C /* NotificationEvents_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9CBE324C74E54006E236C /* NotificationEvents_Tests.swift */; }; 8AE335A824FCF999002B6677 /* Reachability_Vendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE335A524FCF999002B6677 /* Reachability_Vendor.swift */; }; - 8AE335A924FCF999002B6677 /* InternetConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE335A624FCF999002B6677 /* InternetConnection.swift */; }; - 8AE335AA24FCF99E002B6677 /* InternetConnection_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE335A424FCF999002B6677 /* InternetConnection_Tests.swift */; }; 9041E4AD2AE9768800CA2A2A /* MembersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9041E4AC2AE9768800CA2A2A /* MembersResponse.swift */; }; A30C3F20276B428F00DA5968 /* UnknownUserEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30C3F1F276B428F00DA5968 /* UnknownUserEvent.swift */; }; A30C3F22276B4F8800DA5968 /* UnknownUserEvent_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30C3F21276B4F8800DA5968 /* UnknownUserEvent_Tests.swift */; }; @@ -2267,7 +2265,6 @@ C121E8D0274544B100023E4C /* UserListSortingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA640FBD2535CF9200D32944 /* UserListSortingKey.swift */; }; C121E8D1274544B100023E4C /* ChannelMemberListSortingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA640FC02535CFA100D32944 /* ChannelMemberListSortingKey.swift */; }; C121E8D4274544B100023E4C /* NSManagedObject+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D61D9A2510B3FC00EB0624 /* NSManagedObject+Extensions.swift */; }; - C121E8D6274544B100023E4C /* InternetConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE335A624FCF999002B6677 /* InternetConnection.swift */; }; C121E8D7274544B100023E4C /* Reachability_Vendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE335A524FCF999002B6677 /* Reachability_Vendor.swift */; }; C121E8D8274544B100023E4C /* Error+InternetNotAvailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79200D4B25025B81002F4EB1 /* Error+InternetNotAvailable.swift */; }; C121E8DF274544B100023E4C /* StringInterpolation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0BB1602513B5F200CAEFBD /* StringInterpolation+Extensions.swift */; }; @@ -4094,9 +4091,7 @@ 8AC9CBE324C74E54006E236C /* NotificationEvents_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEvents_Tests.swift; sourceTree = ""; }; 8AC9CBE524C74FFE006E236C /* NotificationMarkAllRead.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = NotificationMarkAllRead.json; sourceTree = ""; }; 8ACFBF642507AA440093C6FD /* TypingEventsSender_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingEventsSender_Mock.swift; sourceTree = ""; }; - 8AE335A424FCF999002B6677 /* InternetConnection_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InternetConnection_Tests.swift; sourceTree = ""; }; 8AE335A524FCF999002B6677 /* Reachability_Vendor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reachability_Vendor.swift; sourceTree = ""; }; - 8AE335A624FCF999002B6677 /* InternetConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InternetConnection.swift; sourceTree = ""; }; 9041E4AC2AE9768800CA2A2A /* MembersResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MembersResponse.swift; sourceTree = ""; }; A30C3F1F276B428F00DA5968 /* UnknownUserEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownUserEvent.swift; sourceTree = ""; }; A30C3F21276B4F8800DA5968 /* UnknownUserEvent_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownUserEvent_Tests.swift; sourceTree = ""; }; @@ -6973,7 +6968,6 @@ 8AE335A324FCF95D002B6677 /* InternetConnection */ = { isa = PBXGroup; children = ( - 8AE335A624FCF999002B6677 /* InternetConnection.swift */, 8AE335A524FCF999002B6677 /* Reachability_Vendor.swift */, 79200D4B25025B81002F4EB1 /* Error+InternetNotAvailable.swift */, ); @@ -7914,7 +7908,6 @@ A364D0BE27D12C950029857A /* InternetConnection */ = { isa = PBXGroup; children = ( - 8AE335A424FCF999002B6677 /* InternetConnection_Tests.swift */, 64F70D4A26257FD400C9F979 /* Error+InternetNotAvailable_Tests.swift */, ); path = InternetConnection; @@ -11750,7 +11743,6 @@ AD0E278E2BF789630037554F /* ThreadsRepository.swift in Sources */, 40789D2529F6AC500018C2BB /* AudioSessionConfiguring.swift in Sources */, F6778F9A24F5144F005E7D22 /* EventNotificationCenter.swift in Sources */, - 8AE335A924FCF999002B6677 /* InternetConnection.swift in Sources */, F6ED5F7A2502791F005D7327 /* MissingEventsPayload.swift in Sources */, 4F73F3982B91BD3000563CD9 /* MessageState.swift in Sources */, 88EA9AEE254721C0007EE76B /* MessageReactionType.swift in Sources */, @@ -12137,7 +12129,6 @@ 79D6CF2125FA6ACF00BE2EEC /* MemberEventMiddleware_Tests.swift in Sources */, 40B345F429C46AE500B96027 /* AudioPlaybackState_Tests.swift in Sources */, F61D7C3724FFE17200188A0E /* MessageEditor_Tests.swift in Sources */, - 8AE335AA24FCF99E002B6677 /* InternetConnection_Tests.swift in Sources */, 790A4C4E252E092E001F4A23 /* DevicePayloads_Tests.swift in Sources */, 79D6CE9D25F7D73300BE2EEC /* ChatChannelWatcherListController+SwiftUI_Tests.swift in Sources */, F6CCA24F2512491B004C1859 /* UserTypingStateUpdaterMiddleware_Tests.swift in Sources */, @@ -12987,7 +12978,6 @@ C121E8D4274544B100023E4C /* NSManagedObject+Extensions.swift in Sources */, 4F877D3A2D019E0900CB66EC /* ChannelPinningScope.swift in Sources */, 40789D4029F6AFC40018C2BB /* Debouncer_Tests.swift in Sources */, - C121E8D6274544B100023E4C /* InternetConnection.swift in Sources */, C121E8D7274544B100023E4C /* Reachability_Vendor.swift in Sources */, C121E8D8274544B100023E4C /* Error+InternetNotAvailable.swift in Sources */, 4F83FA472BA43DC3008BD8CD /* MemberList.swift in Sources */, diff --git a/StreamChatUITestsApp/InternetConnection/InternetConnectionMonitor_Mock.swift b/StreamChatUITestsApp/InternetConnection/InternetConnectionMonitor_Mock.swift index 43274200b41..4d3aff7e6ea 100644 --- a/StreamChatUITestsApp/InternetConnection/InternetConnectionMonitor_Mock.swift +++ b/StreamChatUITestsApp/InternetConnection/InternetConnectionMonitor_Mock.swift @@ -10,12 +10,12 @@ import Foundation final class InternetConnectionMonitor_Mock: InternetConnectionMonitor, @unchecked Sendable { var delegate: InternetConnectionDelegate? - var status: InternetConnection.Status = .available(.great) + var status: InternetConnectionStatus = .available(.great) func start() {} func stop() {} - func update(with status: InternetConnection.Status) { + func update(with status: InternetConnectionStatus) { self.status = status delegate?.internetConnectionStatusDidChange(status: status) } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/MockNetwork/InternetConnection_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/MockNetwork/InternetConnection_Mock.swift index 205d9a141cc..972abb472ae 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/MockNetwork/InternetConnection_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/MockNetwork/InternetConnection_Mock.swift @@ -24,7 +24,7 @@ final class InternetConnection_Mock: InternetConnection, @unchecked Sendable { final class InternetConnectionMonitor_Mock: InternetConnectionMonitor, @unchecked Sendable { weak var delegate: InternetConnectionDelegate? - var status: InternetConnection.Status = .unknown { + var status: InternetConnectionStatus = .unknown { didSet { delegate?.internetConnectionStatusDidChange(status: status) } diff --git a/Tests/StreamChatTests/Utils/InternetConnection/InternetConnection_Tests.swift b/Tests/StreamChatTests/Utils/InternetConnection/InternetConnection_Tests.swift deleted file mode 100644 index acc49259db6..00000000000 --- a/Tests/StreamChatTests/Utils/InternetConnection/InternetConnection_Tests.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class InternetConnection_Tests: XCTestCase { - var monitor: InternetConnectionMonitor_Mock! - var internetConnection: InternetConnection! - - override func setUp() { - super.setUp() - monitor = InternetConnectionMonitor_Mock() - internetConnection = InternetConnection(monitor: monitor) - } - - override func tearDown() { - AssertAsync.canBeReleased(&internetConnection) - AssertAsync.canBeReleased(&monitor) - - monitor = nil - internetConnection = nil - super.tearDown() - } - - func test_internetConnection_init() { - // Assert status matches ther monitor - XCTAssertEqual(internetConnection.status, monitor.status) - - // Assert internet connection is set as a delegate - XCTAssertTrue(monitor.delegate === internetConnection) - } - - func test_internetConnection_postsStatusAndAvailabilityNotifications_whenAvailabilityChanges() { - // Set unavailable status - monitor.status = .unavailable - - // Create new status - let newStatus: InternetConnection.Status = .available(.great) - - // Set up expectations for notifications - let notificationExpectations = [ - expectation( - forNotification: .internetConnectionStatusDidChange, - object: internetConnection, - handler: { $0.internetConnectionStatus == newStatus } - ), - expectation( - forNotification: .internetConnectionAvailabilityDidChange, - object: internetConnection, - handler: { $0.internetConnectionStatus == newStatus } - ) - ] - - // Simulate status update - monitor.status = newStatus - - // Assert status is updated - XCTAssertEqual(internetConnection.status, newStatus) - - // Assert both notifications are posted - wait(for: notificationExpectations, timeout: defaultTimeout) - } - - func test_internetConnection_postsStatusNotification_whenQualityChanges() { - // Set status - monitor.status = .available(.constrained) - - // Create status with another quality - let newStatus: InternetConnection.Status = .available(.great) - - // Set up expectation for a notification - let notificationExpectation = expectation( - forNotification: .internetConnectionStatusDidChange, - object: internetConnection, - handler: { $0.internetConnectionStatus == newStatus } - ) - - // Simulate quality update - monitor.status = newStatus - - // Assert status is updated - XCTAssertEqual(internetConnection.status, newStatus) - - // Assert both notifications are posted - wait(for: [notificationExpectation], timeout: defaultTimeout) - } - - func test_internetConnection_stopsMonitorWhenDeinited() throws { - assert(monitor.isStarted) - - internetConnection = nil - XCTAssertFalse(monitor.isStarted) - } -} From c1edb89b214ec98c4cd9d5a0aa643681e81ec9f9 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 09:59:48 +0200 Subject: [PATCH 04/17] Use APIKey and BackgroundTaskScheduler from StreamCore --- .../StreamChat/Config/ChatClientConfig.swift | 18 --- .../BackgroundTaskScheduler.swift | 131 ------------------ .../ConnectionRecoveryHandler.swift | 43 +++--- StreamChat.xcodeproj/project.pbxproj | 10 -- .../BackgroundTaskScheduler_Tests.swift | 103 -------------- 5 files changed, 25 insertions(+), 280 deletions(-) delete mode 100644 Sources/StreamChat/WebSocketClient/BackgroundTaskScheduler.swift delete mode 100644 Tests/StreamChatTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift diff --git a/Sources/StreamChat/Config/ChatClientConfig.swift b/Sources/StreamChat/Config/ChatClientConfig.swift index 7443dc6f44b..a132096f8dd 100644 --- a/Sources/StreamChat/Config/ChatClientConfig.swift +++ b/Sources/StreamChat/Config/ChatClientConfig.swift @@ -227,21 +227,3 @@ extension ChatClientConfig { public var latestMessagesLimit = 5 } } - -/// A struct representing an API key of the chat app. -/// -/// An API key can be obtained by registering on [our website](https://getstream.io/chat/trial/\). -/// -public struct APIKey: Equatable, Sendable { - /// The string representation of the API key - public let apiKeyString: String - - /// Creates a new `APIKey` from the provided string. Fails, if the string is empty. - /// - /// - Warning: The `apiKeyString` must be a non-empty value, otherwise an assertion failure is raised. - /// - public init(_ apiKeyString: String) { - log.assert(apiKeyString.isEmpty == false, "APIKey can't be initialize with an empty string.") - self.apiKeyString = apiKeyString - } -} diff --git a/Sources/StreamChat/WebSocketClient/BackgroundTaskScheduler.swift b/Sources/StreamChat/WebSocketClient/BackgroundTaskScheduler.swift deleted file mode 100644 index 5434887b72a..00000000000 --- a/Sources/StreamChat/WebSocketClient/BackgroundTaskScheduler.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Object responsible for platform specific handling of background tasks -protocol BackgroundTaskScheduler: Sendable { - /// It's your responsibility to finish previously running task. - /// - /// Returns: `false` if system forbid background task, `true` otherwise - func beginTask(expirationHandler: (@Sendable @MainActor () -> Void)?) -> Bool - func endTask() - func startListeningForAppStateUpdates( - onEnteringBackground: @escaping () -> Void, - onEnteringForeground: @escaping () -> Void - ) - func stopListeningForAppStateUpdates() - - var isAppActive: Bool { get } -} - -#if os(iOS) -import UIKit - -class IOSBackgroundTaskScheduler: BackgroundTaskScheduler, @unchecked Sendable { - private lazy var app: UIApplication? = { - // We can't use `UIApplication.shared` directly because there's no way to convince the compiler - // this code is accessible only for non-extension executables. - UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication - }() - - /// The identifier of the currently running background task. `nil` if no background task is running. - private var activeBackgroundTask: UIBackgroundTaskIdentifier? - private let queue = DispatchQueue(label: "io.getstream.IOSBackgroundTaskScheduler", target: .global()) - - var isAppActive: Bool { - StreamConcurrency.onMain { - self.app?.applicationState == .active - } - } - - func beginTask(expirationHandler: (@Sendable @MainActor () -> Void)?) -> Bool { - // Only a single task is allowed at the same time - endTask() - - guard let app else { return false } - let identifier = app.beginBackgroundTask { [weak self] in - self?.endTask() - StreamConcurrency.onMain { - expirationHandler?() - } - } - queue.sync { - self.activeBackgroundTask = identifier - } - return identifier != .invalid - } - - func endTask() { - guard let app else { return } - queue.sync { - if let identifier = self.activeBackgroundTask { - self.activeBackgroundTask = nil - app.endBackgroundTask(identifier) - } - } - } - - private var onEnteringBackground: () -> Void = {} - private var onEnteringForeground: () -> Void = {} - - func startListeningForAppStateUpdates( - onEnteringBackground: @escaping () -> Void, - onEnteringForeground: @escaping () -> Void - ) { - queue.sync { - self.onEnteringForeground = onEnteringForeground - self.onEnteringBackground = onEnteringBackground - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - } - - func stopListeningForAppStateUpdates() { - queue.sync { - self.onEnteringForeground = {} - self.onEnteringBackground = {} - } - - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - } - - @objc private func handleAppDidEnterBackground() { - let callback = queue.sync { onEnteringBackground } - callback() - } - - @objc private func handleAppDidBecomeActive() { - let callback = queue.sync { onEnteringForeground } - callback() - } - - deinit { - endTask() - } -} - -#endif diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift index b135bee57fb..ecf8811b8a3 100644 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift @@ -76,10 +76,12 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler, @unchec private extension DefaultConnectionRecoveryHandler { func subscribeOnNotifications() { - backgroundTaskScheduler?.startListeningForAppStateUpdates( - onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, - onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } - ) + Task { @MainActor in + backgroundTaskScheduler?.startListeningForAppStateUpdates( + onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, + onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } + ) + } internetConnection.notificationCenter.addObserver( self, @@ -90,8 +92,9 @@ private extension DefaultConnectionRecoveryHandler { } func unsubscribeFromNotifications() { - backgroundTaskScheduler?.stopListeningForAppStateUpdates() - + Task { @MainActor [backgroundTaskScheduler] in + backgroundTaskScheduler?.stopListeningForAppStateUpdates() + } internetConnection.notificationCenter.removeObserver( self, name: .internetConnectionStatusDidChange, @@ -106,7 +109,9 @@ extension DefaultConnectionRecoveryHandler { private func appDidBecomeActive() { log.debug("App -> ✅", subsystems: .webSocket) - backgroundTaskScheduler?.endTask() + Task { @MainActor [backgroundTaskScheduler] in + backgroundTaskScheduler?.endTask() + } if canReconnectFromOffline { webSocketClient.connect() @@ -129,17 +134,19 @@ extension DefaultConnectionRecoveryHandler { guard let scheduler = backgroundTaskScheduler else { return } - let succeed = scheduler.beginTask { [weak self] in - log.debug("Background task -> ❌", subsystems: .webSocket) - - self?.disconnectIfNeeded() - } - - if succeed { - log.debug("Background task -> ✅", subsystems: .webSocket) - } else { - // Can't initiate a background task, close the connection - disconnectIfNeeded() + Task { @MainActor [scheduler] in + let succeed = scheduler.beginTask { [weak self] in + log.debug("Background task -> ❌", subsystems: .webSocket) + + self?.disconnectIfNeeded() + } + + if succeed { + log.debug("Background task -> ✅", subsystems: .webSocket) + } else { + // Can't initiate a background task, close the connection + disconnectIfNeeded() + } } } diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 5f35d6e117f..490385174f6 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -2098,7 +2098,6 @@ C121E821274544AD00023E4C /* NotificationEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9CBD524C73689006E236C /* NotificationEvents.swift */; }; C121E822274544AD00023E4C /* WebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F38247FFACB00EAF71D /* WebSocketEngine.swift */; }; C121E823274544AD00023E4C /* URLSessionWebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F772489181200CDEB89 /* URLSessionWebSocketEngine.swift */; }; - C121E825274544AD00023E4C /* BackgroundTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */; }; C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9444247D3DD2001F1104 /* WebSocketClient.swift */; }; C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A618E4424D19D510003D83C /* WebSocketPingController.swift */; }; C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */; }; @@ -2646,7 +2645,6 @@ DAF1BED92506612F003CEDC0 /* MessageController+SwiftUI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF1BED225066107003CEDC0 /* MessageController+SwiftUI_Tests.swift */; }; DAFAD6A124DC476A0043ED06 /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFAD6A024DC476A0043ED06 /* Result+Extensions.swift */; }; DAFAD6A324DD8E1A0043ED06 /* ChannelEditDetailPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFAD6A224DD8E1A0043ED06 /* ChannelEditDetailPayload.swift */; }; - DB05FC1125D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB05FC1025D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift */; }; DB3CCF3F258CF7ED009D5E99 /* ChatMessageLinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3CCF3E258CF7ED009D5E99 /* ChatMessageLinkPreviewView.swift */; }; DB70CFFB25702EB900DDF436 /* ChatMessagePopupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB70CFFA25702EB900DDF436 /* ChatMessagePopupVC.swift */; }; DB8230F2259B8DBF00E7D7FE /* ChatMessageGiphyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8230F1259B8DBF00E7D7FE /* ChatMessageGiphyView.swift */; }; @@ -2655,7 +2653,6 @@ DBC8A564258113F700B20A82 /* ChatThreadVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC8A563258113F700B20A82 /* ChatThreadVC.swift */; }; DBC8A5762581476E00B20A82 /* ChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC8A5752581476E00B20A82 /* ChatMessageListVC.swift */; }; DBF12128258BAFC1001919C6 /* OnlyLinkTappableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF12127258BAFC1001919C6 /* OnlyLinkTappableTextView.swift */; }; - DBF17AE825D48865004517B3 /* BackgroundTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */; }; E3B987EF2844DE1200C2E101 /* MemberRole.json in Resources */ = {isa = PBXBuildFile; fileRef = E3B987EE2844DE1200C2E101 /* MemberRole.json */; }; E3C7A0E02858BA9B006133C3 /* Reusable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */; }; E3C7A0E12858BA9E006133C3 /* Reusable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */; }; @@ -4877,7 +4874,6 @@ DAF1BED625066128003CEDC0 /* MessageController+Combine_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageController+Combine_Tests.swift"; sourceTree = ""; }; DAFAD6A024DC476A0043ED06 /* Result+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Extensions.swift"; sourceTree = ""; }; DAFAD6A224DD8E1A0043ED06 /* ChannelEditDetailPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditDetailPayload.swift; sourceTree = ""; }; - DB05FC1025D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskScheduler_Tests.swift; sourceTree = ""; }; DB3CCF3E258CF7ED009D5E99 /* ChatMessageLinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageLinkPreviewView.swift; sourceTree = ""; }; DB70CFFA25702EB900DDF436 /* ChatMessagePopupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagePopupVC.swift; sourceTree = ""; }; DB8230F1259B8DBF00E7D7FE /* ChatMessageGiphyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageGiphyView.swift; sourceTree = ""; }; @@ -4886,7 +4882,6 @@ DBC8A563258113F700B20A82 /* ChatThreadVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadVC.swift; sourceTree = ""; }; DBC8A5752581476E00B20A82 /* ChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListVC.swift; sourceTree = ""; }; DBF12127258BAFC1001919C6 /* OnlyLinkTappableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlyLinkTappableTextView.swift; sourceTree = ""; }; - DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskScheduler.swift; sourceTree = ""; }; E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Reusable+Extensions.swift"; sourceTree = ""; }; E3B987EE2844DE1200C2E101 /* MemberRole.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MemberRole.json; sourceTree = ""; }; E70120152583EBC90036DACD /* CALayer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CALayer+Extensions.swift"; sourceTree = ""; }; @@ -6162,7 +6157,6 @@ 799C9426247D2FB9001F1104 /* WebSocketClient */ = { isa = PBXGroup; children = ( - DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */, 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */, 799C9444247D3DD2001F1104 /* WebSocketClient.swift */, 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */, @@ -7266,7 +7260,6 @@ A364D08C27D0BD7B0029857A /* WebSocketClient */ = { isa = PBXGroup; children = ( - DB05FC1025D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift */, 797EEA4924FFC37600C81203 /* ConnectionStatus_Tests.swift */, 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */, 430156F126B4523A0006E7EA /* WebSocketConnectPayload_Tests.swift */, @@ -11721,7 +11714,6 @@ DA8407062524F84F005A0F62 /* UserListQuery.swift in Sources */, ADFCA5BA2D1378E2000F515F /* Throttler.swift in Sources */, 4F97F2702BA86491001C4D66 /* UserSearchState.swift in Sources */, - DBF17AE825D48865004517B3 /* BackgroundTaskScheduler.swift in Sources */, 79280F4F2485308100CDEB89 /* DataController.swift in Sources */, ADC40C3426E294EB005B616C /* MessageSearchController+Combine.swift in Sources */, F6ED5F7825027907005D7327 /* MissingEventsRequestBody.swift in Sources */, @@ -12256,7 +12248,6 @@ 843C53AD269373EA00C7D8EA /* VideoAttachmentPayload_Tests.swift in Sources */, 79158CFC25F1341300186102 /* ChannelTruncatedEventMiddleware_Tests.swift in Sources */, 799C9460247D77D6001F1104 /* DatabaseContainer_Tests.swift in Sources */, - DB05FC1125D569590084B6A3 /* BackgroundTaskScheduler_Tests.swift in Sources */, 8A5D3EF924AF749200E2FE35 /* ChannelId_Tests.swift in Sources */, 799C945C247D59D8001F1104 /* ChatClient_Tests.swift in Sources */, 84A1D2E826AAEA3300014712 /* CustomEventRequestBody_Tests.swift in Sources */, @@ -12646,7 +12637,6 @@ C121E823274544AD00023E4C /* URLSessionWebSocketEngine.swift in Sources */, ADB2087F2D849184003F1059 /* MessageReminderListQuery.swift in Sources */, 79D5CDD527EA1BE300BE7D8B /* MessageTranslationsPayload.swift in Sources */, - C121E825274544AD00023E4C /* BackgroundTaskScheduler.swift in Sources */, C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */, C135A1CC28F45F6B0058EFB6 /* AuthenticationRepository.swift in Sources */, C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */, diff --git a/Tests/StreamChatTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift b/Tests/StreamChatTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift deleted file mode 100644 index 7dec1d44837..00000000000 --- a/Tests/StreamChatTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -import StreamChatTestTools -import XCTest - -#if os(iOS) -final class IOSBackgroundTaskScheduler_Tests: XCTestCase { - func test_notifications_foreground() { - // Arrange: Subscribe for app notifications - let scheduler = IOSBackgroundTaskScheduler() - var calledBackground = false - var calledForeground = false - scheduler.startListeningForAppStateUpdates( - onEnteringBackground: { calledBackground = true }, - onEnteringForeground: { calledForeground = true } - ) - - // Act: Send notification - NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) - - // Assert: Only intended closure is called - XCTAssertTrue(calledForeground) - XCTAssertFalse(calledBackground) - } - - func test_notifications_background() { - // Arrange: Subscribe for app notifications - let scheduler = IOSBackgroundTaskScheduler() - var calledBackground = false - var calledForeground = false - scheduler.startListeningForAppStateUpdates( - onEnteringBackground: { calledBackground = true }, - onEnteringForeground: { calledForeground = true } - ) - - // Act: Send notification - NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - - // Assert: Only intended closure is called - XCTAssertFalse(calledForeground) - XCTAssertTrue(calledBackground) - } - - func test_whenSchedulerIsDeallocated_backgroundTaskIsEnded() { - // Create mock scheduler and catch `endTask` - var endTaskCalled = false - var scheduler: IOSBackgroundTaskSchedulerMock? = IOSBackgroundTaskSchedulerMock { - endTaskCalled = true - } - - // Assert `endTask` is not called yet - XCTAssertFalse(endTaskCalled) - - // Remove all strong refs to scheduler - scheduler = nil - - // Assert `endTask` is called - XCTAssertTrue(endTaskCalled) - - // Simulate access to scheduler to eliminate the warning - _ = scheduler - } - - func test_callingBeginMultipleTimes_allTheBackgroundTasksAreEnded() { - var endTaskCallCount = 0 - let scheduler = IOSBackgroundTaskSchedulerMock { - endTaskCallCount += 1 - } - _ = scheduler.beginTask(expirationHandler: nil) - _ = scheduler.beginTask(expirationHandler: nil) - _ = scheduler.beginTask(expirationHandler: nil) - XCTAssertEqual(3, endTaskCallCount) - } - - func test_callingAppStateUpdatesConcurretly() { - let scheduler = IOSBackgroundTaskScheduler() - DispatchQueue.concurrentPerform(iterations: 100) { index in - if index.quotientAndRemainder(dividingBy: 2).remainder == 0 { - scheduler.startListeningForAppStateUpdates(onEnteringBackground: {}, onEnteringForeground: {}) - } else { - scheduler.stopListeningForAppStateUpdates() - } - } - } - - // MARK: - Mocks - - class IOSBackgroundTaskSchedulerMock: IOSBackgroundTaskScheduler, @unchecked Sendable { - let endTaskClosure: () -> Void - - init(endTaskClosure: @escaping () -> Void) { - self.endTaskClosure = endTaskClosure - } - - override func endTask() { - endTaskClosure() - } - } -} -#endif From eb10341a585792a46859caf6d1811260190fa430 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 10:20:47 +0200 Subject: [PATCH 05/17] Use ConnectionStatus from StreamCore --- .../Repositories/ConnectionRepository.swift | 6 +- .../WebSocketClient/ConnectionStatus.swift | 150 -------------- .../WebSocketClient/WebSocketClient.swift | 10 +- .../ConnectionRecoveryHandler.swift | 4 +- StreamChat.xcodeproj/project.pbxproj | 10 - .../Mocks/StreamChat/ChatClient_Mock.swift | 2 +- Tests/StreamChatTests/ChatClient_Tests.swift | 2 +- .../ConnectionController_Tests.swift | 2 +- .../ConnectionRepository_Tests.swift | 18 +- .../ConnectionStatus_Tests.swift | 196 ------------------ .../WebSocketClient_Tests.swift | 30 +-- .../WebSocketPingController_Tests.swift | 6 +- .../ConnectionRecoveryHandler_Tests.swift | 10 +- 13 files changed, 45 insertions(+), 401 deletions(-) delete mode 100644 Sources/StreamChat/WebSocketClient/ConnectionStatus.swift delete mode 100644 Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index e889c293fe5..75287e7cb5a 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -133,9 +133,9 @@ class ConnectionRepository: @unchecked Sendable { let shouldNotifyConnectionIdWaiters: Bool let connectionId: String? switch state { - case let .connected(connectionId: id): + case let .connected(healthCheckInfo: healthCheckInfo): shouldNotifyConnectionIdWaiters = true - connectionId = id + connectionId = healthCheckInfo.connectionId case let .disconnected(source) where source.serverError?.isExpiredTokenError == true: onExpiredToken() shouldNotifyConnectionIdWaiters = false @@ -146,7 +146,7 @@ class ConnectionRepository: @unchecked Sendable { case .initialized, .connecting, .disconnecting, - .waitingForConnectionId: + .authenticating: shouldNotifyConnectionIdWaiters = false connectionId = nil } diff --git a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift b/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift deleted file mode 100644 index bb88a5ec361..00000000000 --- a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -// `ConnectionStatus` is just a simplified and friendlier wrapper around `WebSocketConnectionState`. - -/// Describes the possible states of the client connection to the servers. -public enum ConnectionStatus: Equatable, Sendable { - /// The client is initialized but not connected to the remote server yet. - case initialized - - /// The client is disconnected. This is an initial state. Optionally contains an error, if the connection was disconnected - /// due to an error. - case disconnected(error: ClientError? = nil) - - /// The client is in the process of connecting to the remote servers. - case connecting - - /// The client is connected to the remote server. - case connected - - /// The web socket is disconnecting. - case disconnecting -} - -extension ConnectionStatus { - // In internal initializer used for convering internal `WebSocketConnectionState` to `ChatClientConnectionStatus`. - init(webSocketConnectionState: WebSocketConnectionState) { - switch webSocketConnectionState { - case .initialized: - self = .initialized - - case .connecting, .waitingForConnectionId: - self = .connecting - - case .connected: - self = .connected - - case .disconnecting: - self = .disconnecting - - case let .disconnected(source): - let isWaitingForReconnect = webSocketConnectionState.isAutomaticReconnectionEnabled - self = isWaitingForReconnect ? .connecting : .disconnected(error: source.serverError) - } - } -} - -typealias ConnectionId = String - -/// A web socket connection state. -enum WebSocketConnectionState: Equatable { - /// Provides additional information about the source of disconnecting. - indirect enum DisconnectionSource: Equatable { - /// A user initiated web socket disconnecting. - case userInitiated - - /// The connection timed out while trying to connect. - case timeout(from: WebSocketConnectionState) - - /// A server initiated web socket disconnecting, an optional error object is provided. - case serverInitiated(error: ClientError? = nil) - - /// The system initiated web socket disconnecting. - case systemInitiated - - /// `WebSocketPingController` didn't get a pong response. - case noPongReceived - - /// Returns the underlaying error if connection cut was initiated by the server. - var serverError: ClientError? { - guard case let .serverInitiated(error) = self else { return nil } - - return error - } - } - - /// The initial state meaning that the web socket engine is not yet connected or connecting. - case initialized - - /// The web socket is not connected. Contains the source/reason why the disconnection has happened. - case disconnected(source: DisconnectionSource) - - /// The web socket is connecting - case connecting - - /// The web socket is connected, waiting for the connection id - case waitingForConnectionId - - /// The web socket was connected. - case connected(connectionId: ConnectionId) - - /// The web socket is disconnecting. `source` contains more info about the source of the event. - case disconnecting(source: DisconnectionSource) - - /// Checks if the connection state is connected. - var isConnected: Bool { - if case .connected = self { - return true - } - return false - } - - /// Returns false if the connection state is in the `notConnected` state. - var isActive: Bool { - if case .disconnected = self { - return false - } - return true - } - - /// Returns `true` is the state requires and allows automatic reconnection. - var isAutomaticReconnectionEnabled: Bool { - guard case let .disconnected(source) = self else { return false } - - switch source { - case let .serverInitiated(clientError): - if let wsEngineError = clientError?.underlyingError as? WebSocketEngineError, - wsEngineError.code == WebSocketEngineError.stopErrorCode { - // Don't reconnect on `stop` errors - return false - } - - if let serverInitiatedError = clientError?.underlyingError as? ErrorPayload { - if serverInitiatedError.isInvalidTokenError { - // Don't reconnect on invalid token errors - return false - } - - if serverInitiatedError.isClientError && !serverInitiatedError.isExpiredTokenError { - // Don't reconnect on client side errors unless it is an expired token - // Expired tokens return 401, so it is considered client error. - return false - } - } - - return true - case .systemInitiated: - return true - case .noPongReceived: - return true - case .userInitiated: - return false - case .timeout: - return false - } - } -} diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift index d21159d5b3b..c93f9e2153d 100644 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift @@ -120,7 +120,7 @@ class WebSocketClient: @unchecked Sendable { switch connectionState { // Calling connect in the following states has no effect - case .connecting, .waitingForConnectionId, .connected: + case .connecting, .authenticating, .connected: return default: break } @@ -149,7 +149,7 @@ class WebSocketClient: @unchecked Sendable { switch connectionState { case .initialized, .disconnected, .disconnecting: connectionState = .disconnected(source: source) - case .connecting, .waitingForConnectionId, .connected: + case .connecting, .authenticating, .connected: connectionState = .disconnecting(source: source) } @@ -196,7 +196,7 @@ extension WebSocketClient { extension WebSocketClient: WebSocketEngineDelegate { func webSocketDidConnect() { - connectionState = .waitingForConnectionId + connectionState = .authenticating } func webSocketDidReceiveMessage(_ message: String) { @@ -209,7 +209,7 @@ extension WebSocketClient: WebSocketEngineDelegate { eventNotificationCenter.process(healthCheckEvent, postNotification: false) { [weak self] in self?.engineQueue.async { [weak self] in self?.pingController.pongReceived() - self?.connectionState = .connected(connectionId: healthCheckEvent.connectionId) + self?.connectionState = .connected(healthCheckInfo: HealthCheckInfo(connectionId: healthCheckEvent.connectionId)) } } } else { @@ -235,7 +235,7 @@ extension WebSocketClient: WebSocketEngineDelegate { func webSocketDidDisconnect(error engineError: WebSocketEngineError?) { switch connectionState { - case .connecting, .waitingForConnectionId, .connected: + case .connecting, .authenticating, .connected: let serverError = engineError.map { ClientError.WebSocket(with: $0) } connectionState = .disconnected(source: .serverInitiated(error: serverError)) diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift index ecf8811b8a3..4597f3ae88b 100644 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift @@ -181,7 +181,7 @@ extension DefaultConnectionRecoveryHandler { case .disconnected: scheduleReconnectionTimerIfNeeded() - case .initialized, .waitingForConnectionId, .disconnecting: + case .initialized, .authenticating, .disconnecting: break } } @@ -220,7 +220,7 @@ private extension DefaultConnectionRecoveryHandler { let state = webSocketClient.connectionState switch state { - case .connecting, .waitingForConnectionId, .connected: + case .connecting, .authenticating, .connected: log.debug("Will disconnect automatically from \(state) state", subsystems: .webSocket) return true diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 490385174f6..29d541326e8 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -607,7 +607,6 @@ 797E10A824EAF6DE00353791 /* UniqueId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797E10A724EAF6DE00353791 /* UniqueId.swift */; }; 797EEA4624FFAF4F00C81203 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EEA4524FFAF4F00C81203 /* DataStore.swift */; }; 797EEA4824FFB4C200C81203 /* DataStore_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EEA4724FFB4C200C81203 /* DataStore_Tests.swift */; }; - 797EEA4A24FFC37600C81203 /* ConnectionStatus_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EEA4924FFC37600C81203 /* ConnectionStatus_Tests.swift */; }; 79877A092498E4BC00015F8B /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79877A012498E4BB00015F8B /* User.swift */; }; 79877A0A2498E4BC00015F8B /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79877A022498E4BB00015F8B /* Device.swift */; }; 79877A0B2498E4BC00015F8B /* Member.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79877A032498E4BB00015F8B /* Member.swift */; }; @@ -647,7 +646,6 @@ 799EC85F2853B3BE00F18770 /* BigChannelListPayload.json in Resources */ = {isa = PBXBuildFile; fileRef = 792C87892853B25500B68630 /* BigChannelListPayload.json */; }; 799F611B2530B62C007F218C /* ChannelListQuery_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */; }; 79A0E9AD2498BD0C00E9BD50 /* ChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9AC2498BD0C00E9BD50 /* ChatClient.swift */; }; - 79A0E9B02498C09900E9BD50 /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */; }; 79A0E9BB2498C31300E9BD50 /* TypingStartCleanupMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9B92498C31300E9BD50 /* TypingStartCleanupMiddleware.swift */; }; 79A0E9BE2498C33100E9BD50 /* TypingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9BD2498C33100E9BD50 /* TypingEvent.swift */; }; 79AF43B42632AF1C00E75CDA /* ChannelVisibilityEventMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF43B32632AF1B00E75CDA /* ChannelVisibilityEventMiddleware.swift */; }; @@ -2101,7 +2099,6 @@ C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9444247D3DD2001F1104 /* WebSocketClient.swift */; }; C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A618E4424D19D510003D83C /* WebSocketPingController.swift */; }; C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */; }; - C121E82A274544AD00023E4C /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */; }; C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8933D2025FFAB400054BBFF /* APIPathConvertible.swift */; }; C121E82C274544AD00023E4C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9442247D3DA7001F1104 /* APIClient.swift */; }; C121E82D274544AD00023E4C /* CDNClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649968D3264E660B000515AB /* CDNClient.swift */; }; @@ -3574,7 +3571,6 @@ 797E10A724EAF6DE00353791 /* UniqueId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniqueId.swift; sourceTree = ""; }; 797EEA4524FFAF4F00C81203 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; 797EEA4724FFB4C200C81203 /* DataStore_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore_Tests.swift; sourceTree = ""; }; - 797EEA4924FFC37600C81203 /* ConnectionStatus_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStatus_Tests.swift; sourceTree = ""; }; 798779F62498E47700015F8B /* Member.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Member.json; sourceTree = ""; }; 798779F72498E47700015F8B /* Channel.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Channel.json; sourceTree = ""; }; 798779F82498E47700015F8B /* OtherUser.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = OtherUser.json; sourceTree = ""; }; @@ -3640,7 +3636,6 @@ 79BA19F224B3386B00E11FC2 /* CurrentUserDTO_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserDTO_Tests.swift; sourceTree = ""; }; 79C5CBE725F66DBD00D98001 /* ChatChannelWatcherListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelWatcherListController.swift; sourceTree = ""; }; 79C5CBF025F66E9700D98001 /* ChannelWatcherListQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWatcherListQuery.swift; sourceTree = ""; }; - 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStatus.swift; sourceTree = ""; }; 79CCB66D259CBC4F0082F172 /* ChatChannelNamer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelNamer.swift; sourceTree = ""; }; 79CD959124F9380B00E87377 /* MulticastDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate.swift; sourceTree = ""; }; 79CD959324F9381700E87377 /* MulticastDelegate_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate_Tests.swift; sourceTree = ""; }; @@ -6157,7 +6152,6 @@ 799C9426247D2FB9001F1104 /* WebSocketClient */ = { isa = PBXGroup; children = ( - 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */, 799C9444247D3DD2001F1104 /* WebSocketClient.swift */, 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */, 8A618E4424D19D510003D83C /* WebSocketPingController.swift */, @@ -7260,7 +7254,6 @@ A364D08C27D0BD7B0029857A /* WebSocketClient */ = { isa = PBXGroup; children = ( - 797EEA4924FFC37600C81203 /* ConnectionStatus_Tests.swift */, 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */, 430156F126B4523A0006E7EA /* WebSocketConnectPayload_Tests.swift */, 8A08C6A524D437DF00DEF995 /* WebSocketPingController_Tests.swift */, @@ -11789,7 +11782,6 @@ 88E26D6E2580F34B00F55AB5 /* AttachmentQueueUploader.swift in Sources */, 4F312D0E2C905A2E0073A1BC /* FlagRequestBody.swift in Sources */, 88A00DD02525F08000259AB4 /* ModerationEndpoints.swift in Sources */, - 79A0E9B02498C09900E9BD50 /* ConnectionStatus.swift in Sources */, 4F05C0712C8832C40085B4B7 /* URLRequest+cURL.swift in Sources */, 799C9439247D2FB9001F1104 /* ChannelDTO.swift in Sources */, 799C9443247D3DA7001F1104 /* APIClient.swift in Sources */, @@ -12225,7 +12217,6 @@ AD545E832D5D0389008FD399 /* CurrentUserController+Drafts_Tests.swift in Sources */, ADA9DB892BCEF06B00C4AE3B /* ThreadReadDTO_Tests.swift in Sources */, 792FCB4724A33CC2000290C7 /* EventDataProcessorMiddleware_Tests.swift in Sources */, - 797EEA4A24FFC37600C81203 /* ConnectionStatus_Tests.swift in Sources */, 79877A2B2498E51500015F8B /* UserDTO_Tests.swift in Sources */, ADB208822D8494F0003F1059 /* MessageReminderListQuery_Tests.swift in Sources */, 799F611B2530B62C007F218C /* ChannelListQuery_Tests.swift in Sources */, @@ -12642,7 +12633,6 @@ C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */, C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */, 4F8E53172B7F58C1008C0F9F /* ChatClient+Factory.swift in Sources */, - C121E82A274544AD00023E4C /* ConnectionStatus.swift in Sources */, ADA83B402D974DCC003B3928 /* MessageReminderListController.swift in Sources */, ADA83B412D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */, C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 9735d484c9b..52543b80ffc 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -283,7 +283,7 @@ extension ChatClient { ) return } - webSocketClient(mockWebSocketClient, didUpdateConnectionState: .connected(connectionId: connectionId)) + webSocketClient(mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) } } diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index eda936b79b7..16f14140396 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -928,7 +928,7 @@ final class ChatClient_Tests: XCTestCase { let timerMock = try! XCTUnwrap(client.reconnectionTimeoutHandler as? ScheduledStreamTimer_Mock) // When - client.webSocketClient(client.webSocketClient!, didUpdateConnectionState: .connected(connectionId: .unique)) + client.webSocketClient(client.webSocketClient!, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) // Then XCTAssertEqual(timerMock.stopCallCount, 1) diff --git a/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift b/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift index db83ff2a9f5..3437a9e1053 100644 --- a/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift @@ -84,7 +84,7 @@ final class ChatConnectionController_Tests: XCTestCase { // Simulate connection status updates. client.webSocketClient?.simulateConnectionStatus(.connecting) - client.webSocketClient?.simulateConnectionStatus(.connected(connectionId: .unique)) + client.webSocketClient?.simulateConnectionStatus(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) // Assert updates are received AssertAsync.willBeEqual(delegate.didUpdateConnectionStatus_statuses, [.connecting, .connected]) diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index e83d022b2ea..58f24abfdf5 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -91,7 +91,7 @@ final class ConnectionRepository_Tests: XCTestCase { } // Simulate error scenario (change status + force waiters completion) - webSocketClient.mockedConnectionState = .waitingForConnectionId + webSocketClient.mockedConnectionState = .authenticating repository.completeConnectionIdWaiters(connectionId: nil) waitForExpectations(timeout: defaultTimeout) @@ -257,8 +257,8 @@ final class ConnectionRepository_Tests: XCTestCase { let pairs: [(WebSocketConnectionState, ConnectionStatus)] = [ (.initialized, .initialized), (.connecting, .connecting), - (.waitingForConnectionId, .connecting), - (.connected(connectionId: "123"), .connected), + (.authenticating, .connecting), + (.connected(healthCheckInfo: HealthCheckInfo(connectionId: "123")), .connected), (.disconnecting(source: .userInitiated), .disconnecting), (.disconnecting(source: .noPongReceived), .disconnecting), (.disconnected(source: .userInitiated), .disconnected(error: nil)), @@ -288,8 +288,8 @@ final class ConnectionRepository_Tests: XCTestCase { let pairs: [(WebSocketConnectionState, Bool)] = [ (.initialized, false), (.connecting, false), - (.waitingForConnectionId, false), - (.connected(connectionId: "123"), true), + (.authenticating, false), + (.connected(healthCheckInfo: HealthCheckInfo(connectionId: "123")), true), (.disconnecting(source: .userInitiated), false), (.disconnecting(source: .noPongReceived), false), (.disconnected(source: .userInitiated), true), @@ -332,8 +332,8 @@ final class ConnectionRepository_Tests: XCTestCase { let pairs: [(WebSocketConnectionState, ConnectionId?)] = [ (.initialized, nil), (.connecting, nil), - (.waitingForConnectionId, nil), - (.connected(connectionId: "123"), "123"), + (.authenticating, nil), + (.connected(healthCheckInfo: HealthCheckInfo(connectionId: "123")), "123"), (.disconnecting(source: .userInitiated), nil), (.disconnected(source: .userInitiated), nil) ] @@ -402,7 +402,7 @@ final class ConnectionRepository_Tests: XCTestCase { } func test_handleConnectionUpdate_whenNoError_shouldNOTExecuteRefreshTokenBlock() { - let states: [WebSocketConnectionState] = [.connecting, .initialized, .connected(connectionId: .newUniqueId), .waitingForConnectionId] + let states: [WebSocketConnectionState] = [.connecting, .initialized, .connected(healthCheckInfo: HealthCheckInfo(connectionId: .newUniqueId)), .authenticating] for state in states { repository.handleConnectionUpdate(state: state, onExpiredToken: { @@ -510,7 +510,7 @@ final class ConnectionRepository_Tests: XCTestCase { // Set initial connectionId let initialConnectionId = "initial-connection-id" repository.handleConnectionUpdate( - state: .connected(connectionId: initialConnectionId), + state: .connected(healthCheckInfo: HealthCheckInfo(connectionId: initialConnectionId)), onExpiredToken: {} ) XCTAssertEqual(repository.connectionId, initialConnectionId) diff --git a/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift b/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift deleted file mode 100644 index db8349006fa..00000000000 --- a/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class ChatClientConnectionStatus_Tests: XCTestCase { - func test_wsConnectionState_isTranslatedCorrectly() { - let testError = ClientError(with: TestError()) - - let invalidTokenError = ClientError( - with: ErrorPayload( - code: ClosedRange.tokenInvalidErrorCodes.lowerBound, - message: .unique, - statusCode: .unique - ) - ) - - let pairs: [(WebSocketConnectionState, ConnectionStatus)] = [ - (.initialized, .initialized), - (.connecting, .connecting), - (.waitingForConnectionId, .connecting), - (.disconnected(source: .systemInitiated), .connecting), - (.disconnected(source: .noPongReceived), .connecting), - (.disconnected(source: .serverInitiated(error: nil)), .connecting), - (.disconnected(source: .serverInitiated(error: testError)), .connecting), - (.disconnected(source: .serverInitiated(error: invalidTokenError)), .disconnected(error: invalidTokenError)), - (.connected(connectionId: .unique), .connected), - (.disconnecting(source: .noPongReceived), .disconnecting), - (.disconnecting(source: .serverInitiated(error: testError)), .disconnecting), - (.disconnecting(source: .systemInitiated), .disconnecting), - (.disconnecting(source: .userInitiated), .disconnecting), - (.disconnected(source: .userInitiated), .disconnected(error: nil)) - ] - - pairs.forEach { - XCTAssertEqual($1, ConnectionStatus(webSocketConnectionState: $0)) - } - } -} - -final class WebSocketConnectionState_Tests: XCTestCase { - // MARK: - Server error - - func test_disconnectionSource_serverError() { - // Create test error - let testError = ClientError(with: TestError()) - - // Create pairs of disconnection source and expected server error - let testCases: [(WebSocketConnectionState.DisconnectionSource, ClientError?)] = [ - (.userInitiated, nil), - (.systemInitiated, nil), - (.noPongReceived, nil), - (.serverInitiated(error: nil), nil), - (.serverInitiated(error: testError), testError) - ] - - // Iterate pairs - testCases.forEach { source, serverError in - // Assert returned server error matches expected one - XCTAssertEqual(source.serverError, serverError) - } - } - - // MARK: - Automatic reconnection - - func test_isAutomaticReconnectionEnabled_whenNotDisconnected_returnsFalse() { - // Create array of connection states excluding disconnected state - let connectionStates: [WebSocketConnectionState] = [ - .initialized, - .connecting, - .waitingForConnectionId, - .connected(connectionId: .unique), - .disconnecting(source: .userInitiated), - .disconnecting(source: .systemInitiated), - .disconnecting(source: .noPongReceived), - .disconnecting(source: .serverInitiated(error: nil)) - ] - - // Iterate conneciton states - for state in connectionStates { - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedBySystem_returnsTrue() { - // Create disconnected state initated by the sytem - let state: WebSocketConnectionState = .disconnected(source: .systemInitiated) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedWithNoPongReceived_returnsTrue() { - // Create disconnected state when pong does not come - let state: WebSocketConnectionState = .disconnected(source: .noPongReceived) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithoutError_returnsTrue() { - // Create disconnected state initiated by the server without any error - let state: WebSocketConnectionState = .disconnected(source: .serverInitiated(error: nil)) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithRandomError_returnsTrue() { - // Create disconnected state intiated by the server with random error - let state: WebSocketConnectionState = .disconnected(source: .serverInitiated(error: ClientError(.unique))) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByUser_returnsFalse() { - // Create disconnected state initated by the user - let state: WebSocketConnectionState = .disconnected(source: .userInitiated) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithInvalidTokenError_returnsFalse() { - // Create invalid token error - let invalidTokenError = ErrorPayload( - code: ClosedRange.tokenInvalidErrorCodes.lowerBound, - message: .unique, - statusCode: .unique - ) - - // Create disconnected state intiated by the server with invalid token error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: invalidTokenError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithExpiredToken_returnsTrue() { - // Create expired token error - let expiredTokenError = ErrorPayload( - code: StreamErrorCode.expiredToken, - message: .unique, - statusCode: .unique - ) - - // Create disconnected state intiated by the server with invalid token error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: expiredTokenError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithClientError_returnsFalse() { - // Create client error - let clientError = ErrorPayload( - code: .unique, - message: .unique, - statusCode: ClosedRange.clientErrorCodes.lowerBound - ) - - // Create disconnected state intiated by the server with client error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: clientError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithStopError_returnsFalse() { - // Create stop error - let stopError = WebSocketEngineError( - reason: .unique, - code: WebSocketEngineError.stopErrorCode, - engineError: nil - ) - - // Create disconnected state intiated by the server with stop error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: stopError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } -} diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift index 2443a29cf79..b0c2cd93fc6 100644 --- a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift @@ -163,27 +163,27 @@ final class WebSocketClient_Tests: XCTestCase { // Simulate the engine is connected and check the connection state is updated engine!.simulateConnectionSuccess() - AssertAsync.willBeEqual(webSocketClient.connectionState, .waitingForConnectionId) + AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) // Simulate a health check event is received and the connection state is updated decoder.decodedEvent = .success(HealthCheckEvent(connectionId: connectionId)) engine!.simulateMessageReceived() - AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(connectionId: connectionId)) + AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) } func test_callingConnect_whenAlreadyConnected_hasNoEffect() { // Simulate connection test_connectionFlow() - assert(webSocketClient.connectionState == .connected(connectionId: connectionId)) + assert(webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) assert(engine!.connect_calledCount == 1) // Call connect and assert it has no effect webSocketClient.connect() AssertAsync { Assert.staysTrue(self.engine!.connect_calledCount == 1) - Assert.staysTrue(self.webSocketClient.connectionState == .connected(connectionId: self.connectionId)) + Assert.staysTrue(self.webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: self.connectionId))) } } @@ -191,7 +191,7 @@ final class WebSocketClient_Tests: XCTestCase { // Simulate connection test_connectionFlow() - assert(webSocketClient.connectionState == .connected(connectionId: connectionId)) + assert(webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) assert(engine!.disconnect_calledCount == 0) // Call `disconnect`, it should change connection state and call `disconnect` on the engine @@ -282,7 +282,7 @@ final class WebSocketClient_Tests: XCTestCase { ) engine!.simulateMessageReceived() - AssertAsync.staysEqual(webSocketClient.connectionState, .connected(connectionId: connectionId)) + AssertAsync.staysEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) } // MARK: - Ping Controller @@ -291,7 +291,7 @@ final class WebSocketClient_Tests: XCTestCase { test_connectionFlow() AssertAsync.willBeEqual( pingController.connectionStateDidChange_connectionStates, - [.connecting, .waitingForConnectionId, .connected(connectionId: connectionId)] + [.connecting, .authenticating, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))] ) } @@ -383,10 +383,10 @@ final class WebSocketClient_Tests: XCTestCase { let connectionStates: [WebSocketConnectionState] = [ .connecting, .connecting, // duplicate state should be ignored - .waitingForConnectionId, - .waitingForConnectionId, // duplicate state should be ignored - .connected(connectionId: connectionId), - .connected(connectionId: connectionId), // duplicate state should be ignored + .authenticating, + .authenticating, // duplicate state should be ignored + .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), + .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), // duplicate state should be ignored .disconnecting(source: .userInitiated), .disconnecting(source: .userInitiated), // duplicate state should be ignored .disconnected(source: .userInitiated), @@ -397,7 +397,7 @@ final class WebSocketClient_Tests: XCTestCase { let expectedEvents = [ WebSocketConnectionState.connecting, // states 0...3 - .connected(connectionId: connectionId), // states 4...5 + .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), // states 4...5 .disconnecting(source: .userInitiated), // states 6...7 .disconnected(source: .userInitiated) // states 8...9 ].map { @@ -440,7 +440,7 @@ final class WebSocketClient_Tests: XCTestCase { // Simulate the engine is connected and check the connection state is updated engine!.simulateConnectionSuccess() - AssertAsync.willBeEqual(webSocketClient.connectionState, .waitingForConnectionId) + AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) // Simulate a health check event is received and the connection state is updated let payloadCurrentUser = dummyCurrentUser @@ -456,7 +456,7 @@ final class WebSocketClient_Tests: XCTestCase { // We should see `CurrentUserDTO` being saved before we get connectionId AssertAsync.willBeEqual(currentUser?.user.id, payloadCurrentUser.id) - AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(connectionId: connectionId)) + AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) } func test_whenHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() throws { @@ -477,7 +477,7 @@ final class WebSocketClient_Tests: XCTestCase { engine!.simulateConnectionSuccess() // Wait for the connection state to be propagated to web-socket client - AssertAsync.willBeEqual(webSocketClient.connectionState, .waitingForConnectionId) + AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) // Simulate received health check event let healthCheckEvent = HealthCheckEvent(connectionId: .unique) diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift index b3df799f438..81bda359474 100644 --- a/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift @@ -37,7 +37,7 @@ final class WebSocketPingController_Tests: XCTestCase { XCTAssertEqual(delegate.sendPing_calledCount, 0) // Set the connection state as connected - pingController.connectionStateDidChange(.connected(connectionId: .unique)) + pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) // Simulate time passed 3x pingTimeInterval (+1 for margin errors) time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) @@ -46,14 +46,14 @@ final class WebSocketPingController_Tests: XCTestCase { let oldPingCount = delegate.sendPing_calledCount // Set the connection state to not connected and check `sendPing` is no longer called - pingController.connectionStateDidChange(.waitingForConnectionId) + pingController.connectionStateDidChange(.authenticating) time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) XCTAssertEqual(delegate.sendPing_calledCount, oldPingCount) } func test_disconnectOnNoPongReceived_called_whenNoPongReceived() throws { // Set the connection state as connected - pingController.connectionStateDidChange(.connected(connectionId: .unique)) + pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) assert(delegate.sendPing_calledCount == 0) diff --git a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift index 200bb318fa7..dc0515b40df 100644 --- a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift +++ b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift @@ -526,7 +526,7 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(connectionId: "124")) + handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: "124"))) XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) @@ -536,7 +536,7 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(connectionId: "124")) + handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: "124"))) XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) @@ -589,7 +589,7 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .waitingForConnectionId) + handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .authenticating) XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) } @@ -637,8 +637,8 @@ private extension ConnectionRecoveryHandler_Tests { let ws = mockChatClient.mockWebSocketClient ws.simulateConnectionStatus(.connecting) - ws.simulateConnectionStatus(.waitingForConnectionId) - ws.simulateConnectionStatus(.connected(connectionId: .unique)) + ws.simulateConnectionStatus(.authenticating) + ws.simulateConnectionStatus(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) } func disconnectWebSocket(source: WebSocketConnectionState.DisconnectionSource) { From be8a8989863976f8c86d8785d0b7cfa0d7c77e05 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 11:09:10 +0200 Subject: [PATCH 06/17] Fix failing tests --- .../ConnectionRecoveryHandler.swift | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift index 4597f3ae88b..cdb61d7cfac 100644 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift @@ -76,12 +76,10 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler, @unchec private extension DefaultConnectionRecoveryHandler { func subscribeOnNotifications() { - Task { @MainActor in - backgroundTaskScheduler?.startListeningForAppStateUpdates( - onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, - onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } - ) - } + backgroundTaskScheduler?.startListeningForAppStateUpdates( + onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, + onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } + ) internetConnection.notificationCenter.addObserver( self, @@ -92,9 +90,7 @@ private extension DefaultConnectionRecoveryHandler { } func unsubscribeFromNotifications() { - Task { @MainActor [backgroundTaskScheduler] in - backgroundTaskScheduler?.stopListeningForAppStateUpdates() - } + backgroundTaskScheduler?.stopListeningForAppStateUpdates() internetConnection.notificationCenter.removeObserver( self, name: .internetConnectionStatusDidChange, @@ -109,9 +105,7 @@ extension DefaultConnectionRecoveryHandler { private func appDidBecomeActive() { log.debug("App -> ✅", subsystems: .webSocket) - Task { @MainActor [backgroundTaskScheduler] in - backgroundTaskScheduler?.endTask() - } + backgroundTaskScheduler?.endTask() if canReconnectFromOffline { webSocketClient.connect() @@ -134,19 +128,17 @@ extension DefaultConnectionRecoveryHandler { guard let scheduler = backgroundTaskScheduler else { return } - Task { @MainActor [scheduler] in - let succeed = scheduler.beginTask { [weak self] in - log.debug("Background task -> ❌", subsystems: .webSocket) - - self?.disconnectIfNeeded() - } + let succeed = scheduler.beginTask { [weak self] in + log.debug("Background task -> ❌", subsystems: .webSocket) - if succeed { - log.debug("Background task -> ✅", subsystems: .webSocket) - } else { - // Can't initiate a background task, close the connection - disconnectIfNeeded() - } + self?.disconnectIfNeeded() + } + + if succeed { + log.debug("Background task -> ✅", subsystems: .webSocket) + } else { + // Can't initiate a background task, close the connection + disconnectIfNeeded() } } From a9651f5d18d024a9c0591320657065340b6bd775 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 12:58:18 +0200 Subject: [PATCH 07/17] Fix tests --- .../Engine/WebSocketEngine.swift | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift b/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift index 19683613f17..41fb869aea6 100644 --- a/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift +++ b/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift @@ -21,31 +21,3 @@ protocol WebSocketEngineDelegate: AnyObject { func webSocketDidDisconnect(error: WebSocketEngineError?) func webSocketDidReceiveMessage(_ message: String) } - -struct WebSocketEngineError: Error { - static let stopErrorCode = 1000 - - let reason: String - let code: Int - let engineError: Error? - - var localizedDescription: String { reason } -} - -extension WebSocketEngineError { - init(error: Error?) { - if let error = error { - self.init( - reason: error.localizedDescription, - code: (error as NSError).code, - engineError: error - ) - } else { - self.init( - reason: "Unknown", - code: 0, - engineError: nil - ) - } - } -} From 23725d867fb1d8e046eb661bcbd427c990c887f7 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Thu, 30 Oct 2025 17:09:34 +0200 Subject: [PATCH 08/17] Use WebSocketClient from StreamCore --- .../StreamChat/ChatClient+Environment.swift | 28 +- Sources/StreamChat/ChatClient.swift | 9 +- Sources/StreamChat/ChatClientFactory.swift | 6 +- .../Repositories/ConnectionRepository.swift | 34 +- Sources/StreamChat/StateLayer/Chat.swift | 2 +- .../Engine/URLSessionWebSocketEngine.swift | 163 ----- .../Engine/WebSocketEngine.swift | 23 - .../Events/ConnectionEvents.swift | 21 + .../WebSocketClient/Events/EventDecoder.swift | 14 +- .../WebSocketClient/WebSocketClient.swift | 302 -------- .../WebSocketPingController.swift | 100 --- .../ConnectionRecoveryHandler.swift | 278 -------- .../Workers/EventNotificationCenter.swift | 33 +- StreamChat.xcodeproj/project.pbxproj | 54 -- .../Mocks/StreamChat/ChatClient_Mock.swift | 24 +- .../ConnectionRepository_Mock.swift | 5 +- .../WebSocketClient_Mock.swift | 27 +- .../WebSocketEngine_Mock.swift | 10 +- .../WebSocketPingController_Mock.swift | 1 + .../ConnectionRecoveryHandler_Mock.swift | 6 +- .../EventNotificationCenter_Mock.swift | 2 +- .../WebSocketPingController_Delegate.swift | 20 - Tests/StreamChatTests/ChatClient_Tests.swift | 93 ++- .../ConnectionController_Tests.swift | 8 + .../EventsController+Combine_Tests.swift | 2 +- .../EventsController+SwiftUI_Tests.swift | 2 +- .../ConnectionRepository_Tests.swift | 21 +- .../WebSocketClient_Tests.swift | 550 --------------- .../WebSocketPingController_Tests.swift | 87 --- .../ConnectionRecoveryHandler_Tests.swift | 650 ------------------ .../EventNotificationCenter_Tests.swift | 28 +- 31 files changed, 218 insertions(+), 2385 deletions(-) delete mode 100644 Sources/StreamChat/WebSocketClient/Engine/URLSessionWebSocketEngine.swift delete mode 100644 Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift delete mode 100644 Sources/StreamChat/WebSocketClient/WebSocketClient.swift delete mode 100644 Sources/StreamChat/WebSocketClient/WebSocketPingController.swift delete mode 100644 Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift delete mode 100644 TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift delete mode 100644 Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift delete mode 100644 Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift delete mode 100644 Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 5159cb8a9d2..0b37aea036d 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -25,15 +25,15 @@ extension ChatClient { var webSocketClientBuilder: (@Sendable ( _ sessionConfiguration: URLSessionConfiguration, - _ requestEncoder: RequestEncoder, _ eventDecoder: AnyEventDecoder, - _ notificationCenter: EventNotificationCenter + _ notificationCenter: PersistentEventNotificationCenter ) -> WebSocketClient)? = { WebSocketClient( sessionConfiguration: $0, - requestEncoder: $1, - eventDecoder: $2, - eventNotificationCenter: $3 + eventDecoder: $1, + eventNotificationCenter: $2, + webSocketClientType: .coordinator, + connectRequest: nil ) } @@ -57,7 +57,7 @@ extension ChatClient { var eventDecoderBuilder: @Sendable () -> EventDecoder = { EventDecoder() } - var notificationCenterBuilder: @Sendable (_ database: DatabaseContainer, _ manualEventHandler: ManualEventHandler?) -> EventNotificationCenter = { EventNotificationCenter(database: $0, manualEventHandler: $1) } + var notificationCenterBuilder: @Sendable (_ database: DatabaseContainer, _ manualEventHandler: ManualEventHandler?) -> PersistentEventNotificationCenter = { PersistentEventNotificationCenter(database: $0, manualEventHandler: $1) } var internetConnection: @Sendable (_ center: NotificationCenter, _ monitor: InternetConnectionMonitor) -> InternetConnection = { InternetConnection(notificationCenter: $0, monitor: $1) @@ -76,6 +76,7 @@ extension ChatClient { var connectionRepositoryBuilder: @Sendable ( _ isClientInActiveMode: Bool, _ syncRepository: SyncRepository, + _ webSocketEncoder: RequestEncoder?, _ webSocketClient: WebSocketClient?, _ apiClient: APIClient, _ timerType: TimerScheduling.Type @@ -83,9 +84,10 @@ extension ChatClient { ConnectionRepository( isClientInActiveMode: $0, syncRepository: $1, - webSocketClient: $2, - apiClient: $3, - timerType: $4 + webSocketEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 ) } @@ -110,7 +112,6 @@ extension ChatClient { var connectionRecoveryHandlerBuilder: @Sendable ( _ webSocketClient: WebSocketClient, _ eventNotificationCenter: EventNotificationCenter, - _ syncRepository: SyncRepository, _ backgroundTaskScheduler: BackgroundTaskScheduler?, _ internetConnection: InternetConnection, _ keepConnectionAliveInBackground: Bool @@ -118,12 +119,11 @@ extension ChatClient { DefaultConnectionRecoveryHandler( webSocketClient: $0, eventNotificationCenter: $1, - syncRepository: $2, - backgroundTaskScheduler: $3, - internetConnection: $4, + backgroundTaskScheduler: $2, + internetConnection: $3, reconnectionStrategy: DefaultRetryStrategy(), reconnectionTimerType: DefaultTimer.self, - keepConnectionAliveInBackground: $5 + keepConnectionAliveInBackground: $4 ) } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 204922a8b92..ee6fbdaf8bb 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -52,7 +52,7 @@ public class ChatClient: @unchecked Sendable { private(set) var connectionRecoveryHandler: ConnectionRecoveryHandler? /// The notification center used to send and receive notifications about incoming events. - private(set) var eventNotificationCenter: EventNotificationCenter + private(set) var eventNotificationCenter: PersistentEventNotificationCenter /// The registry that contains all the attachment payloads associated with their attachment types. /// For the meantime this is a static property to avoid breaking changes. On v5, this can be changed. @@ -99,6 +99,7 @@ public class ChatClient: @unchecked Sendable { /// The `WebSocketClient` instance `Client` uses to communicate with Stream WS servers. let webSocketClient: WebSocketClient? + let webSocketEncoder: RequestEncoder? /// The `DatabaseContainer` instance `Client` uses to store and cache data. let databaseContainer: DatabaseContainer @@ -184,13 +185,13 @@ public class ChatClient: @unchecked Sendable { channelListUpdater ) let webSocketClient = factory.makeWebSocketClient( - requestEncoder: webSocketEncoder, urlSessionConfiguration: urlSessionConfiguration, eventNotificationCenter: eventNotificationCenter ) let connectionRepository = environment.connectionRepositoryBuilder( config.isClientInActiveMode, syncRepository, + webSocketEncoder, webSocketClient, apiClient, environment.timerType @@ -207,6 +208,7 @@ public class ChatClient: @unchecked Sendable { self.databaseContainer = databaseContainer self.apiClient = apiClient self.webSocketClient = webSocketClient + self.webSocketEncoder = webSocketEncoder self.eventNotificationCenter = eventNotificationCenter self.offlineRequestsRepository = offlineRequestsRepository self.connectionRepository = connectionRepository @@ -268,7 +270,6 @@ public class ChatClient: @unchecked Sendable { connectionRecoveryHandler = environment.connectionRecoveryHandlerBuilder( webSocketClient, eventNotificationCenter, - syncRepository, environment.backgroundTaskSchedulerBuilder(), environment.internetConnection(eventNotificationCenter, environment.internetMonitor), config.staysConnectedInBackground @@ -718,7 +719,7 @@ extension ChatClient: AuthenticationRepositoryDelegate { } extension ChatClient: ConnectionStateDelegate { - func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) { + public func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) { connectionRepository.handleConnectionUpdate( state: state, onExpiredToken: { [weak self] in diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 73ce9239706..b05eda1296e 100644 --- a/Sources/StreamChat/ChatClientFactory.swift +++ b/Sources/StreamChat/ChatClientFactory.swift @@ -65,13 +65,11 @@ class ChatClientFactory { } func makeWebSocketClient( - requestEncoder: RequestEncoder, urlSessionConfiguration: URLSessionConfiguration, - eventNotificationCenter: EventNotificationCenter + eventNotificationCenter: PersistentEventNotificationCenter ) -> WebSocketClient? { environment.webSocketClientBuilder?( urlSessionConfiguration, - requestEncoder, EventDecoder(), eventNotificationCenter ) @@ -114,7 +112,7 @@ class ChatClientFactory { func makeEventNotificationCenter( databaseContainer: DatabaseContainer, currentUserId: @escaping () -> UserId? - ) -> EventNotificationCenter { + ) -> PersistentEventNotificationCenter { let center = environment.notificationCenterBuilder(databaseContainer, nil) let middlewares: [EventMiddleware] = [ EventDataProcessorMiddleware(), diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index 75287e7cb5a..db920d0bba2 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -22,8 +22,10 @@ class ConnectionRepository: @unchecked Sendable { set { connectionQueue.async(flags: .barrier) { self._connectionId = newValue }} } + let webSocketConnectEndpoint = AllocatedUnfairLock?>(nil) let isClientInActiveMode: Bool private let syncRepository: SyncRepository + private let webSocketEncoder: RequestEncoder? private let webSocketClient: WebSocketClient? private let apiClient: APIClient private let timerType: TimerScheduling.Type @@ -31,12 +33,14 @@ class ConnectionRepository: @unchecked Sendable { init( isClientInActiveMode: Bool, syncRepository: SyncRepository, + webSocketEncoder: RequestEncoder?, webSocketClient: WebSocketClient?, apiClient: APIClient, timerType: TimerScheduling.Type ) { self.isClientInActiveMode = isClientInActiveMode self.syncRepository = syncRepository + self.webSocketEncoder = webSocketEncoder self.webSocketClient = webSocketClient self.apiClient = apiClient self.timerType = timerType @@ -80,6 +84,7 @@ class ConnectionRepository: @unchecked Sendable { } } } + updateWebSocketConnectURLRequest() webSocketClient?.connect() } @@ -114,18 +119,38 @@ class ConnectionRepository: @unchecked Sendable { /// Updates the WebSocket endpoint to use the passed token and user information for the connection func updateWebSocketEndpoint(with token: Token, userInfo: UserInfo?) { - webSocketClient?.connectEndpoint = .webSocketConnect(userInfo: userInfo ?? .init(id: token.userId)) + webSocketConnectEndpoint.value = .webSocketConnect(userInfo: userInfo ?? .init(id: token.userId)) } - + /// Updates the WebSocket endpoint to use the passed user id func updateWebSocketEndpoint(with currentUserId: UserId) { - webSocketClient?.connectEndpoint = .webSocketConnect(userInfo: UserInfo(id: currentUserId)) + webSocketConnectEndpoint.value = .webSocketConnect(userInfo: UserInfo(id: currentUserId)) + } + + private func updateWebSocketConnectURLRequest() { + guard let webSocketClient, let webSocketEncoder, let webSocketConnectEndpoint = webSocketConnectEndpoint.value else { return } + let request: URLRequest? = { + do { + return try webSocketEncoder.encodeRequest(for: webSocketConnectEndpoint) + } catch { + log.error(error.localizedDescription, error: error) + return nil + } + }() + guard let request else { return } + webSocketClient.connectRequest = request } func handleConnectionUpdate( state: WebSocketConnectionState, onExpiredToken: () -> Void ) { + let event = ConnectionStatusUpdated(webSocketConnectionState: state) + if event.connectionStatus != connectionStatus { + // Publish Connection event with the new state + webSocketClient?.publishEvent(event) + } + connectionStatus = .init(webSocketConnectionState: state) // We should notify waiters if connectionId was obtained (i.e. state is .connected) @@ -136,6 +161,9 @@ class ConnectionRepository: @unchecked Sendable { case let .connected(healthCheckInfo: healthCheckInfo): shouldNotifyConnectionIdWaiters = true connectionId = healthCheckInfo.connectionId + syncRepository.syncLocalState { + log.info("Local state sync completed", subsystems: .offlineSupport) + } case let .disconnected(source) where source.serverError?.isExpiredTokenError == true: onExpiredToken() shouldNotifyConnectionIdWaiters = false diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 738a947cd53..8d877db698b 100644 --- a/Sources/StreamChat/StateLayer/Chat.swift +++ b/Sources/StreamChat/StateLayer/Chat.swift @@ -1501,7 +1501,7 @@ extension Chat { func dispatchSubscribeHandler(_ event: E, callback: @escaping @Sendable (E) -> Void) where E: Event { Task.mainActor { guard let cid = try? self.cid else { return } - guard EventNotificationCenter.channelFilter(cid: cid, event: event) else { return } + guard PersistentEventNotificationCenter.channelFilter(cid: cid, event: event) else { return } callback(event) } } diff --git a/Sources/StreamChat/WebSocketClient/Engine/URLSessionWebSocketEngine.swift b/Sources/StreamChat/WebSocketClient/Engine/URLSessionWebSocketEngine.swift deleted file mode 100644 index 88e3acbd59d..00000000000 --- a/Sources/StreamChat/WebSocketClient/Engine/URLSessionWebSocketEngine.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -// Note: Synchronization of the instance is handled by WebSocketClient, therefore all the properties are unsafe here. -class URLSessionWebSocketEngine: NSObject, WebSocketEngine, @unchecked Sendable { - private weak var task: URLSessionWebSocketTask? { - didSet { - oldValue?.cancel() - } - } - - let request: URLRequest - private var session: URLSession? - let delegateOperationQueue: OperationQueue - let sessionConfiguration: URLSessionConfiguration - var urlSessionDelegateHandler: URLSessionDelegateHandler? - - var callbackQueue: DispatchQueue { delegateOperationQueue.underlyingQueue! } - - weak var delegate: WebSocketEngineDelegate? - - required init(request: URLRequest, sessionConfiguration: URLSessionConfiguration, callbackQueue: DispatchQueue) { - self.request = request - self.sessionConfiguration = sessionConfiguration - - delegateOperationQueue = OperationQueue() - delegateOperationQueue.underlyingQueue = callbackQueue - - super.init() - } - - func connect() { - urlSessionDelegateHandler = makeURLSessionDelegateHandler() - - session = URLSession( - configuration: sessionConfiguration, - delegate: urlSessionDelegateHandler, - delegateQueue: delegateOperationQueue - ) - - log.debug( - "Making Websocket upgrade request: \(String(describing: request.url?.absoluteString))\n" - + "Headers:\n\(String(describing: request.allHTTPHeaderFields))\n" - + "Query items:\n\(request.queryItems.prettyPrinted)", - subsystems: .httpRequests - ) - - task = session?.webSocketTask(with: request) - doRead() - task?.resume() - } - - func disconnect() { - task?.cancel(with: .normalClosure, reason: nil) - session?.invalidateAndCancel() - - session = nil - task = nil - urlSessionDelegateHandler = nil - } - - func sendPing() { - task?.sendPing { _ in } - } - - private func doRead() { - task?.receive { [weak self] result in - guard let self = self else { - return - } - - switch result { - case let .success(message): - if case let .string(string) = message { - self.callbackQueue.async { [weak self] in - self?.delegate?.webSocketDidReceiveMessage(string) - } - } - self.doRead() - - case let .failure(error): - if error.isSocketNotConnectedError { - log.debug("Web Socket got disconnected with error: \(error)", subsystems: .webSocket) - } else { - log.error("Failed receiving Web Socket Message with error: \(error)", subsystems: .webSocket) - } - } - } - } - - private func makeURLSessionDelegateHandler() -> URLSessionDelegateHandler { - let urlSessionDelegateHandler = URLSessionDelegateHandler() - urlSessionDelegateHandler.onOpen = { [weak self] _ in - self?.callbackQueue.async { [weak self] in - self?.delegate?.webSocketDidConnect() - } - } - - urlSessionDelegateHandler.onClose = { [weak self] closeCode, reason in - var error: WebSocketEngineError? - - if let reasonData = reason, let reasonString = String(data: reasonData, encoding: .utf8) { - error = WebSocketEngineError( - reason: reasonString, - code: closeCode.rawValue, - engineError: nil - ) - } - - self?.callbackQueue.async { [weak self, error] in - self?.delegate?.webSocketDidDisconnect(error: error) - } - } - - urlSessionDelegateHandler.onCompletion = { [weak self] error in - // If we received this callback because we closed the WS connection - // intentionally, `error` param will be `nil`. - // Delegate is already informed with `didCloseWith` callback, - // so we don't need to call delegate again. - guard let error = error else { return } - - self?.callbackQueue.async { [weak self] in - self?.delegate?.webSocketDidDisconnect(error: WebSocketEngineError(error: error)) - } - } - - return urlSessionDelegateHandler - } - - deinit { - disconnect() - } -} - -final class URLSessionDelegateHandler: NSObject, URLSessionDataDelegate, URLSessionWebSocketDelegate, @unchecked Sendable { - var onOpen: ((_ protocol: String?) -> Void)? - var onClose: ((_ code: URLSessionWebSocketTask.CloseCode, _ reason: Data?) -> Void)? - var onCompletion: ((Error?) -> Void)? - - public func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol protocol: String? - ) { - onOpen?(`protocol`) - } - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - onClose?(closeCode, reason) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - onCompletion?(error) - } -} diff --git a/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift b/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift deleted file mode 100644 index 41fb869aea6..00000000000 --- a/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -protocol WebSocketEngine: AnyObject, Sendable { - var request: URLRequest { get } - var callbackQueue: DispatchQueue { get } - var delegate: WebSocketEngineDelegate? { get set } - - init(request: URLRequest, sessionConfiguration: URLSessionConfiguration, callbackQueue: DispatchQueue) - - func connect() - func disconnect() - func sendPing() -} - -protocol WebSocketEngineDelegate: AnyObject { - func webSocketDidConnect() - func webSocketDidDisconnect(error: WebSocketEngineError?) - func webSocketDidReceiveMessage(_ message: String) -} diff --git a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift index 62866c27b23..fe2b3668fc0 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift @@ -32,6 +32,27 @@ public final class HealthCheckEvent: ConnectionEvent, EventDTO, Sendable { channel: nil ) } + + public func healthcheck() -> HealthCheckInfo? { + HealthCheckInfo(connectionId: connectionId) + } +} + +struct WebSocketErrorEvent: Event { + let payload: ErrorPayload + + func error() -> (any Error)? { + payload + } +} + +struct ErrorPayloadContainer: Decodable { + /// A server error was received. + let error: ErrorPayload + + func toEvent() -> Event { + WebSocketErrorEvent(payload: error) + } } /// Emitted when `Client` changes it's connection status. You can listen to this event and indicate the different connection diff --git a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift index 1ad0a907f0d..b8f95bdacd4 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift @@ -17,19 +17,11 @@ struct EventDecoder { return try decoder.decode(UnknownUserEvent.self, from: data) } catch let error as ClientError.IgnoredEventType { throw error + } catch { + let errorPayload = try decoder.decode(ErrorPayloadContainer.self, from: data) + return errorPayload.toEvent() } } } -extension ClientError { - public final class IgnoredEventType: ClientError, @unchecked Sendable { - override public var localizedDescription: String { "The incoming event type is not supported. Ignoring." } - } -} - -/// A type-erased wrapper protocol for `EventDecoder`. -protocol AnyEventDecoder { - func decode(from: Data) throws -> Event -} - extension EventDecoder: AnyEventDecoder {} diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift deleted file mode 100644 index c93f9e2153d..00000000000 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -class WebSocketClient: @unchecked Sendable { - /// The notification center `WebSocketClient` uses to send notifications about incoming events. - let eventNotificationCenter: EventNotificationCenter - - /// The batch of events received via the web-socket that wait to be processed. - private(set) lazy var eventsBatcher = environment.eventBatcherBuilder { [weak self] events, completion in - self?.eventNotificationCenter.process(events, completion: completion) - } - - /// The current state the web socket connection. - @Atomic private(set) var connectionState: WebSocketConnectionState = .initialized { - didSet { - engineQueue.async { [connectionState, pingController] in - pingController.connectionStateDidChange(connectionState) - } - - guard connectionState != oldValue else { return } - - log.info("Web socket connection state changed: \(connectionState)", subsystems: .webSocket) - - connectionStateDelegate?.webSocketClient(self, didUpdateConnectionState: connectionState) - - let previousStatus = ConnectionStatus(webSocketConnectionState: oldValue) - let event = ConnectionStatusUpdated(webSocketConnectionState: connectionState) - - if event.connectionStatus != previousStatus { - // Publish Connection event with the new state - eventsBatcher.append(event) - } - } - } - - weak var connectionStateDelegate: ConnectionStateDelegate? - - /// The endpoint used for creating a web socket connection. - /// - /// Changing this value doesn't automatically update the existing connection. You need to manually call `disconnect` - /// and `connect` to make a new connection to the updated endpoint. - var connectEndpoint: Endpoint? - - /// The decoder used to decode incoming events - private let eventDecoder: AnyEventDecoder - - /// The web socket engine used to make the actual WS connection - private(set) var engine: WebSocketEngine? - - /// The queue on which web socket engine methods are called - private let engineQueue: DispatchQueue = .init(label: "io.getStream.chat.core.web_socket_engine_queue", qos: .userInitiated) - - private let requestEncoder: RequestEncoder - - /// The session config used for the web socket engine - private let sessionConfiguration: URLSessionConfiguration - - /// An object containing external dependencies of `WebSocketClient` - private let environment: Environment - - private(set) lazy var pingController: WebSocketPingController = { - let pingController = environment.createPingController(environment.timerType, engineQueue) - pingController.delegate = self - return pingController - }() - - private func createEngineIfNeeded(for connectEndpoint: Endpoint) throws -> WebSocketEngine { - let request: URLRequest - do { - request = try requestEncoder.encodeRequest(for: connectEndpoint) - } catch { - log(.error, message: error.localizedDescription, error: error) - throw error - } - - if let existedEngine = engine, existedEngine.request == request { - return existedEngine - } - - let engine = environment.createEngine(request, sessionConfiguration, engineQueue) - engine.delegate = self - return engine - } - - init( - sessionConfiguration: URLSessionConfiguration, - requestEncoder: RequestEncoder, - eventDecoder: AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - environment: Environment = .init() - ) { - self.environment = environment - self.requestEncoder = requestEncoder - self.sessionConfiguration = sessionConfiguration - self.eventDecoder = eventDecoder - - self.eventNotificationCenter = eventNotificationCenter - eventsBatcher = environment.eventBatcherBuilder { [eventNotificationCenter] events, completion in - eventNotificationCenter.process(events, completion: completion) - } - pingController = environment.createPingController(environment.timerType, engineQueue) - pingController.delegate = self - } - - func initialize() { - connectionState = .initialized - } - - /// Connects the web connect. - /// - /// Calling this method has no effect is the web socket is already connected, or is in the connecting phase. - func connect() { - guard let endpoint = connectEndpoint else { - log.assertionFailure("Attempt to connect `web-socket` while endpoint is missing", subsystems: .webSocket) - return - } - - switch connectionState { - // Calling connect in the following states has no effect - case .connecting, .authenticating, .connected: - return - default: break - } - - do { - engine = try createEngineIfNeeded(for: endpoint) - } catch { - return - } - - connectionState = .connecting - - engineQueue.async { [weak engine] in - engine?.connect() - } - } - - /// Disconnects the web socket. - /// - /// Calling this function has no effect, if the connection is in an inactive state. - /// - Parameter source: Additional information about the source of the disconnection. Default value is `.userInitiated`. - func disconnect( - source: WebSocketConnectionState.DisconnectionSource = .userInitiated, - completion: @escaping @Sendable () -> Void - ) { - switch connectionState { - case .initialized, .disconnected, .disconnecting: - connectionState = .disconnected(source: source) - case .connecting, .authenticating, .connected: - connectionState = .disconnecting(source: source) - } - - engineQueue.async { [engine, eventsBatcher] in - engine?.disconnect() - - eventsBatcher.processImmediately(completion: completion) - } - } -} - -protocol ConnectionStateDelegate: AnyObject { - func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) -} - -extension WebSocketClient { - /// An object encapsulating all dependencies of `WebSocketClient`. - struct Environment { - typealias CreatePingController = (_ timerType: TimerScheduling.Type, _ timerQueue: DispatchQueue) -> WebSocketPingController - - typealias CreateEngine = ( - _ request: URLRequest, - _ sessionConfiguration: URLSessionConfiguration, - _ callbackQueue: DispatchQueue - ) -> WebSocketEngine - - var timerType: TimerScheduling.Type = DefaultTimer.self - - var createPingController: CreatePingController = WebSocketPingController.init - - var createEngine: CreateEngine = { - URLSessionWebSocketEngine(request: $0, sessionConfiguration: $1, callbackQueue: $2) - } - - var eventBatcherBuilder: ( - _ handler: @escaping @Sendable ([Event], @escaping @Sendable () -> Void) -> Void - ) -> EventBatcher = { - Batcher(period: 0.5, handler: $0) - } - } -} - -// MARK: - Web Socket Delegate - -extension WebSocketClient: WebSocketEngineDelegate { - func webSocketDidConnect() { - connectionState = .authenticating - } - - func webSocketDidReceiveMessage(_ message: String) { - do { - let messageData = Data(message.utf8) - log.debug("Event received:\n\(messageData.debugPrettyPrintedJSON)", subsystems: .webSocket) - - let event = try eventDecoder.decode(from: messageData) - if let healthCheckEvent = event as? HealthCheckEvent { - eventNotificationCenter.process(healthCheckEvent, postNotification: false) { [weak self] in - self?.engineQueue.async { [weak self] in - self?.pingController.pongReceived() - self?.connectionState = .connected(healthCheckInfo: HealthCheckInfo(connectionId: healthCheckEvent.connectionId)) - } - } - } else { - eventsBatcher.append(event) - } - } catch is ClientError.IgnoredEventType { - log.info("Skipping unsupported event type with payload: \(message)", subsystems: .webSocket) - } catch { - log.error(error) - - // Check if the message contains an error object from the server - let webSocketError = message - .data(using: .utf8) - .flatMap { try? JSONDecoder.default.decode(WebSocketErrorContainer.self, from: $0) } - .map { ClientError.WebSocket(with: $0?.error) } - - if let webSocketError = webSocketError { - // If there is an error from the server, the connection is about to be disconnected - connectionState = .disconnecting(source: .serverInitiated(error: webSocketError)) - } - } - } - - func webSocketDidDisconnect(error engineError: WebSocketEngineError?) { - switch connectionState { - case .connecting, .authenticating, .connected: - let serverError = engineError.map { ClientError.WebSocket(with: $0) } - - connectionState = .disconnected(source: .serverInitiated(error: serverError)) - - case let .disconnecting(source): - connectionState = .disconnected(source: source) - - case .initialized, .disconnected: - log.error("Web socket can not be disconnected when in \(connectionState) state.") - } - } -} - -// MARK: - Ping Controller Delegate - -extension WebSocketClient: WebSocketPingControllerDelegate { - func sendPing() { - engineQueue.async { [weak engine] in - engine?.sendPing() - } - } - - func disconnectOnNoPongReceived() { - disconnect(source: .noPongReceived) { - log.debug("Websocket is disconnected because of no pong received", subsystems: .webSocket) - } - } -} - -// MARK: - Notifications - -extension Notification.Name { - /// The name of the notification posted when a new event is published/ - static let NewEventReceived = Notification.Name("io.getStream.chat.core.new_event_received") -} - -extension Notification { - private static let eventKey = "io.getStream.chat.core.event_key" - - init(newEventReceived event: Event, sender: Any) { - self.init(name: .NewEventReceived, object: sender, userInfo: [Self.eventKey: event]) - } - - var event: Event? { - userInfo?[Self.eventKey] as? Event - } -} - -// MARK: - Test helpers - -#if TESTS -extension WebSocketClient { - /// Simulates connection status change - func simulateConnectionStatus(_ status: WebSocketConnectionState) { - connectionState = status - } -} -#endif - -/// WebSocket Error -struct WebSocketErrorContainer: Decodable { - /// A server error was received. - let error: ErrorPayload -} diff --git a/Sources/StreamChat/WebSocketClient/WebSocketPingController.swift b/Sources/StreamChat/WebSocketClient/WebSocketPingController.swift deleted file mode 100644 index 25fa0360db3..00000000000 --- a/Sources/StreamChat/WebSocketClient/WebSocketPingController.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A delegate to control `WebSocketClient` connection by `WebSocketPingController`. -protocol WebSocketPingControllerDelegate: AnyObject { - /// `WebSocketPingController` will call this function periodically to keep a connection alive. - func sendPing() - - /// `WebSocketPingController` will call this function to force disconnect `WebSocketClient`. - func disconnectOnNoPongReceived() -} - -/// The controller manages ping and pong timers. It sends ping periodically to keep a web socket connection alive. -/// After ping is sent, a pong waiting timer is started, and if pong does not come, a forced disconnect is called. -class WebSocketPingController: @unchecked Sendable { - /// The time interval to ping connection to keep it alive. - static let pingTimeInterval: TimeInterval = 25 - /// The time interval for pong timeout. - static let pongTimeoutTimeInterval: TimeInterval = 3 - - private let timerType: TimerScheduling.Type - private let timerQueue: DispatchQueue - - /// The timer used for scheduling `ping` calls - private var pingTimerControl: RepeatingTimerControl? - - /// The pong timeout timer. - private var pongTimeoutTimer: TimerControl? - - /// A delegate to control `WebSocketClient` connection by `WebSocketPingController`. - weak var delegate: WebSocketPingControllerDelegate? - - deinit { - cancelPongTimeoutTimer() - } - - /// Creates a ping controller. - /// - Parameters: - /// - timerType: a timer type. - /// - timerQueue: a timer dispatch queue. - init(timerType: TimerScheduling.Type, timerQueue: DispatchQueue) { - self.timerType = timerType - self.timerQueue = timerQueue - } - - /// `WebSocketClient` should call this when the connection state did change. - func connectionStateDidChange(_ connectionState: WebSocketConnectionState) { - guard delegate != nil else { return } - - cancelPongTimeoutTimer() - schedulePingTimerIfNeeded() - - if connectionState.isConnected { - log.info("Resume WebSocket Ping timer") - pingTimerControl?.resume() - } else { - pingTimerControl?.suspend() - } - } - - // MARK: - Ping - - private func sendPing() { - schedulePongTimeoutTimer() - - log.info("WebSocket Ping") - delegate?.sendPing() - } - - func pongReceived() { - log.info("WebSocket Pong") - cancelPongTimeoutTimer() - } - - // MARK: Timers - - private func schedulePingTimerIfNeeded() { - guard pingTimerControl == nil else { return } - pingTimerControl = timerType.scheduleRepeating(timeInterval: Self.pingTimeInterval, queue: timerQueue) { [weak self] in - self?.sendPing() - } - } - - private func schedulePongTimeoutTimer() { - cancelPongTimeoutTimer() - // Start pong timeout timer. - pongTimeoutTimer = timerType.schedule(timeInterval: Self.pongTimeoutTimeInterval, queue: timerQueue) { [weak self] in - log.info("WebSocket Pong timeout. Reconnect") - self?.delegate?.disconnectOnNoPongReceived() - } - } - - private func cancelPongTimeoutTimer() { - pongTimeoutTimer?.cancel() - pongTimeoutTimer = nil - } -} diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift deleted file mode 100644 index cdb61d7cfac..00000000000 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import CoreData -import Foundation - -/// The type that keeps track of active chat components and asks them to reconnect when it's needed -protocol ConnectionRecoveryHandler: ConnectionStateDelegate { - func start() - func stop() -} - -/// The type is designed to obtain missing events that happened in watched channels while user -/// was not connected to the web-socket. -/// -/// The object listens for `ConnectionStatusUpdated` events -/// and remembers the `CurrentUserDTO.lastReceivedEventDate` when status becomes `connecting`. -/// -/// When the status becomes `connected` the `/sync` endpoint is called -/// with `lastReceivedEventDate` and `cids` of watched channels. -/// -/// We remember `lastReceivedEventDate` when state becomes `connecting` to catch the last event date -/// before the `HealthCheck` override the `lastReceivedEventDate` with the recent date. -/// -final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler, @unchecked Sendable { - // MARK: - Properties - - private let webSocketClient: WebSocketClient - private let eventNotificationCenter: EventNotificationCenter - private let syncRepository: SyncRepository - private let backgroundTaskScheduler: BackgroundTaskScheduler? - private let internetConnection: InternetConnection - private let reconnectionTimerType: TimerScheduling.Type - private var reconnectionStrategy: RetryStrategy - private var reconnectionTimer: TimerControl? - private let keepConnectionAliveInBackground: Bool - - // MARK: - Init - - init( - webSocketClient: WebSocketClient, - eventNotificationCenter: EventNotificationCenter, - syncRepository: SyncRepository, - backgroundTaskScheduler: BackgroundTaskScheduler?, - internetConnection: InternetConnection, - reconnectionStrategy: RetryStrategy, - reconnectionTimerType: TimerScheduling.Type, - keepConnectionAliveInBackground: Bool - ) { - self.webSocketClient = webSocketClient - self.eventNotificationCenter = eventNotificationCenter - self.syncRepository = syncRepository - self.backgroundTaskScheduler = backgroundTaskScheduler - self.internetConnection = internetConnection - self.reconnectionStrategy = reconnectionStrategy - self.reconnectionTimerType = reconnectionTimerType - self.keepConnectionAliveInBackground = keepConnectionAliveInBackground - } - - func start() { - subscribeOnNotifications() - } - - func stop() { - unsubscribeFromNotifications() - cancelReconnectionTimer() - } - - deinit { - stop() - } -} - -// MARK: - Subscriptions - -private extension DefaultConnectionRecoveryHandler { - func subscribeOnNotifications() { - backgroundTaskScheduler?.startListeningForAppStateUpdates( - onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, - onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } - ) - - internetConnection.notificationCenter.addObserver( - self, - selector: #selector(internetConnectionAvailabilityDidChange(_:)), - name: .internetConnectionAvailabilityDidChange, - object: nil - ) - } - - func unsubscribeFromNotifications() { - backgroundTaskScheduler?.stopListeningForAppStateUpdates() - internetConnection.notificationCenter.removeObserver( - self, - name: .internetConnectionStatusDidChange, - object: nil - ) - } -} - -// MARK: - Event handlers - -extension DefaultConnectionRecoveryHandler { - private func appDidBecomeActive() { - log.debug("App -> ✅", subsystems: .webSocket) - - backgroundTaskScheduler?.endTask() - - if canReconnectFromOffline { - webSocketClient.connect() - } - } - - private func appDidEnterBackground() { - log.debug("App -> 💤", subsystems: .webSocket) - - guard canBeDisconnected else { - // Client is not trying to connect nor connected - return - } - - guard keepConnectionAliveInBackground else { - // We immediately disconnect - disconnectIfNeeded() - return - } - - guard let scheduler = backgroundTaskScheduler else { return } - - let succeed = scheduler.beginTask { [weak self] in - log.debug("Background task -> ❌", subsystems: .webSocket) - - self?.disconnectIfNeeded() - } - - if succeed { - log.debug("Background task -> ✅", subsystems: .webSocket) - } else { - // Can't initiate a background task, close the connection - disconnectIfNeeded() - } - } - - @objc private func internetConnectionAvailabilityDidChange(_ notification: Notification) { - guard let isAvailable = notification.internetConnectionStatus?.isAvailable else { - return - } - - log.debug("Internet -> \(isAvailable ? "✅" : "❌")", subsystems: .webSocket) - - if isAvailable { - if canReconnectFromOffline { - webSocketClient.connect() - } - } else { - disconnectIfNeeded() - } - } - - func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) { - log.debug("Connection state: \(state)", subsystems: .webSocket) - - switch state { - case .connecting: - cancelReconnectionTimer() - - case .connected: - reconnectionStrategy.resetConsecutiveFailures() - syncRepository.syncLocalState { - log.info("Local state sync completed", subsystems: .offlineSupport) - } - - case .disconnected: - scheduleReconnectionTimerIfNeeded() - case .initialized, .authenticating, .disconnecting: - break - } - } - - var canReconnectFromOffline: Bool { - guard backgroundTaskScheduler?.isAppActive ?? true else { - log.debug("Reconnection is not possible (app 💤)", subsystems: .webSocket) - return false - } - - switch webSocketClient.connectionState { - case .disconnected(let source) where source == .userInitiated: - return false - case .initialized, .connected: - return false - default: - break - } - - return true - } -} - -// MARK: - Disconnection - -private extension DefaultConnectionRecoveryHandler { - func disconnectIfNeeded() { - guard canBeDisconnected else { return } - - webSocketClient.disconnect(source: .systemInitiated) { - log.debug("Did disconnect automatically", subsystems: .webSocket) - } - } - - var canBeDisconnected: Bool { - let state = webSocketClient.connectionState - - switch state { - case .connecting, .authenticating, .connected: - log.debug("Will disconnect automatically from \(state) state", subsystems: .webSocket) - - return true - default: - log.debug("Disconnect is not needed in \(state) state", subsystems: .webSocket) - - return false - } - } -} - -// MARK: - Reconnection Timer - -private extension DefaultConnectionRecoveryHandler { - func scheduleReconnectionTimerIfNeeded() { - guard canReconnectAutomatically else { return } - - scheduleReconnectionTimer() - } - - func scheduleReconnectionTimer() { - let delay = reconnectionStrategy.getDelayAfterTheFailure() - - log.debug("Timer ⏳ \(delay) sec", subsystems: .webSocket) - - reconnectionTimer = reconnectionTimerType.schedule( - timeInterval: delay, - queue: .main, - onFire: { [weak self] in - log.debug("Timer 🔥", subsystems: .webSocket) - - if self?.canReconnectAutomatically == true { - self?.webSocketClient.connect() - } - } - ) - } - - func cancelReconnectionTimer() { - guard reconnectionTimer != nil else { return } - - log.debug("Timer ❌", subsystems: .webSocket) - - reconnectionTimer?.cancel() - reconnectionTimer = nil - } - - var canReconnectAutomatically: Bool { - guard webSocketClient.connectionState.isAutomaticReconnectionEnabled else { - log.debug("Reconnection is not required (\(webSocketClient.connectionState))", subsystems: .webSocket) - return false - } - - guard backgroundTaskScheduler?.isAppActive ?? true else { - log.debug("Reconnection is not possible (app 💤)", subsystems: .webSocket) - return false - } - - log.debug("Will reconnect automatically", subsystems: .webSocket) - - return true - } -} diff --git a/Sources/StreamChat/Workers/EventNotificationCenter.swift b/Sources/StreamChat/Workers/EventNotificationCenter.swift index 83f18b401eb..c614f6d47cd 100644 --- a/Sources/StreamChat/Workers/EventNotificationCenter.swift +++ b/Sources/StreamChat/Workers/EventNotificationCenter.swift @@ -6,7 +6,7 @@ import Combine import Foundation /// The type is designed to pre-process some incoming `Event` via middlewares before being published -class EventNotificationCenter: NotificationCenter, @unchecked Sendable { +class PersistentEventNotificationCenter: NotificationCenter, EventNotificationCenter, @unchecked Sendable { private(set) var middlewares: [EventMiddleware] = [] /// The database used when evaluating middlewares. @@ -97,36 +97,7 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } } -extension EventNotificationCenter { - func process(_ event: Event, postNotification: Bool = true, completion: (@Sendable () -> Void)? = nil) { - process([event], postNotifications: postNotification, completion: completion) - } -} - -extension EventNotificationCenter { - func subscribe( - to event: E.Type, - filter: @escaping (E) -> Bool = { _ in true }, - handler: @escaping (E) -> Void - ) -> AnyCancellable where E: Event { - publisher(for: .NewEventReceived) - .compactMap { $0.event as? E } - .filter(filter) - .receive(on: DispatchQueue.main) - .sink(receiveValue: handler) - } - - func subscribe( - filter: @escaping (Event) -> Bool = { _ in true }, - handler: @escaping (Event) -> Void - ) -> AnyCancellable { - publisher(for: .NewEventReceived) - .compactMap(\.event) - .filter(filter) - .receive(on: DispatchQueue.main) - .sink(receiveValue: handler) - } - +extension PersistentEventNotificationCenter { static func channelFilter(cid: ChannelId, event: Event) -> Bool { switch event { case let channelEvent as ChannelSpecificEvent: diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 29d541326e8..e8f1033775f 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -524,11 +524,9 @@ 79280F49248520B300CDEB89 /* EventDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F48248520B300CDEB89 /* EventDecoder.swift */; }; 79280F4B248523C000CDEB89 /* ConnectionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F4A248523C000CDEB89 /* ConnectionEvents.swift */; }; 79280F4F2485308100CDEB89 /* DataController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F4E2485308100CDEB89 /* DataController.swift */; }; - 79280F782489181200CDEB89 /* URLSessionWebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F772489181200CDEB89 /* URLSessionWebSocketEngine.swift */; }; 792921C524C0479700116BBB /* ChannelListUpdater_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792921C424C0479700116BBB /* ChannelListUpdater_Tests.swift */; }; 792921C924C056F400116BBB /* ChannelListController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792921C824C056F400116BBB /* ChannelListController_Tests.swift */; }; 792A4F1D247FEA2200EAF71D /* ChannelListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F1C247FEA2200EAF71D /* ChannelListController.swift */; }; - 792A4F39247FFACB00EAF71D /* WebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F38247FFACB00EAF71D /* WebSocketEngine.swift */; }; 792A4F3F247FFDE700EAF71D /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F3D247FFDE700EAF71D /* Codable+Extensions.swift */; }; 792A4F462480107A00EAF71D /* ChannelQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F422480107A00EAF71D /* ChannelQuery.swift */; }; 792A4F472480107A00EAF71D /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F432480107A00EAF71D /* Filter.swift */; }; @@ -634,7 +632,6 @@ 799C943B247D2FB9001F1104 /* MessageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C942D247D2FB9001F1104 /* MessageDTO.swift */; }; 799C943E247D2FB9001F1104 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9431247D2FB9001F1104 /* ChatMessage.swift */; }; 799C9443247D3DA7001F1104 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9442247D3DA7001F1104 /* APIClient.swift */; }; - 799C9445247D3DD2001F1104 /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9444247D3DD2001F1104 /* WebSocketClient.swift */; }; 799C9447247D50F3001F1104 /* Worker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9446247D50F3001F1104 /* Worker.swift */; }; 799C9449247D5211001F1104 /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9448247D5211001F1104 /* MessageSender.swift */; }; 799C944C247D5766001F1104 /* ChannelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C944B247D5766001F1104 /* ChannelController.swift */; }; @@ -1060,7 +1057,6 @@ 88F836612578D1A80039AEC8 /* ChatMessageActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F836602578D1A80039AEC8 /* ChatMessageActionItem.swift */; }; 8A0175F02501174000570345 /* TypingEventsSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0175EF2501174000570345 /* TypingEventsSender.swift */; }; 8A0175F425013B6400570345 /* TypingEventSender_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0175F325013B6400570345 /* TypingEventSender_Tests.swift */; }; - 8A08C6A624D437DF00DEF995 /* WebSocketPingController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A08C6A524D437DF00DEF995 /* WebSocketPingController_Tests.swift */; }; 8A0C3BBC24C0947400CAFD19 /* UserEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0C3BBB24C0947400CAFD19 /* UserEvents.swift */; }; 8A0C3BC924C0BBAB00CAFD19 /* UserEvents_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0C3BC824C0BBAB00CAFD19 /* UserEvents_Tests.swift */; }; 8A0C3BD424C1DF2100CAFD19 /* MessageEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0C3BD324C1DF2100CAFD19 /* MessageEvents.swift */; }; @@ -1077,7 +1073,6 @@ 8A0D64AB24E57BF20017A3C0 /* ChannelListPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0D64AA24E57BF20017A3C0 /* ChannelListPayload_Tests.swift */; }; 8A0D64AE24E5853F0017A3C0 /* DataController_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0D64AD24E5853F0017A3C0 /* DataController_Tests.swift */; }; 8A5D3EF924AF749200E2FE35 /* ChannelId_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5D3EF824AF749200E2FE35 /* ChannelId_Tests.swift */; }; - 8A618E4524D19D510003D83C /* WebSocketPingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A618E4424D19D510003D83C /* WebSocketPingController.swift */; }; 8A62704E24B8660A0040BFD6 /* EventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A62704D24B8660A0040BFD6 /* EventType.swift */; }; 8A62705024B867190040BFD6 /* EventPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A62704F24B867190040BFD6 /* EventPayload.swift */; }; 8A62705C24BE2BC00040BFD6 /* TypingEvent_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A62705B24BE2BC00040BFD6 /* TypingEvent_Tests.swift */; }; @@ -1194,7 +1189,6 @@ A311B43527E8BC8400CFCF6D /* DataController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BA7827E377CD00BBF4FA /* DataController_Delegate.swift */; }; A311B43627E8BC8400CFCF6D /* ChannelMemberController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BAC127E4DB7100BBF4FA /* ChannelMemberController_Delegate.swift */; }; A311B43727E8BC8400CFCF6D /* ChannelController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BAA327E38B7700BBF4FA /* ChannelController_Delegate.swift */; }; - A311B43827E8BC8400CFCF6D /* WebSocketPingController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BADC27E4E86400BBF4FA /* WebSocketPingController_Delegate.swift */; }; A311B43927E8BC8400CFCF6D /* ChannelListController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BAAA27E4D1F100BBF4FA /* ChannelListController_Delegate.swift */; }; A311B43A27E8BC8400CFCF6D /* UserListController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BACC27E4DD9000BBF4FA /* UserListController_Delegate.swift */; }; A311B43B27E8BC8400CFCF6D /* ChannelWatcherListController_Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C7BAB627E4D8B200BBF4FA /* ChannelWatcherListController_Delegate.swift */; }; @@ -1277,7 +1271,6 @@ A3813B4C2825C8030076E838 /* CustomChatMessageListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3813B4B2825C8030076E838 /* CustomChatMessageListRouter.swift */; }; A3813B4E2825C8A30076E838 /* ThreadVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3813B4D2825C8A30076E838 /* ThreadVC.swift */; }; A382131E2805C8AC0068D30E /* TestsEnvironmentSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A382131D2805C8AC0068D30E /* TestsEnvironmentSetup.swift */; }; - A3960E0D27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */; }; A39A8AE7263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39A8AE6263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift */; }; A39B040B27F196F200D6B18A /* StreamChatUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39B040A27F196F200D6B18A /* StreamChatUITests.swift */; }; A3A0C9A1283E955200B18DA4 /* ChannelResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824445AA27EA364300DB2FD8 /* ChannelResponses.swift */; }; @@ -1430,7 +1423,6 @@ A3F65E3327EB6F63003F6256 /* AssertNetworkRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F65E3227EB6F63003F6256 /* AssertNetworkRequest.swift */; }; A3F65E3427EB70BF003F6256 /* AssertAsync+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D15D8027E9CF5A006B34D7 /* AssertAsync+Events.swift */; }; A3F65E3627EB70E0003F6256 /* EventLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69C4BC524F66CC200A3D740 /* EventLogger.swift */; }; - A3F65E3727EB7161003F6256 /* WebSocketClient_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */; }; A3F65E3827EB716A003F6256 /* TypingStartCleanupMiddleware_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A0E9B82498C31300E9BD50 /* TypingStartCleanupMiddleware_Tests.swift */; }; A3F65E3A27EB72F6003F6256 /* Event+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D15D9127EA0125006B34D7 /* Event+Equatable.swift */; }; AC1E16FF269C70530040548B /* String+Extensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDB5412269C6F2A007CD465 /* String+Extensions_Tests.swift */; }; @@ -2094,10 +2086,6 @@ C121E81F274544AD00023E4C /* MemberEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0CC9E424C5FEA900705CF9 /* MemberEvents.swift */; }; C121E820274544AD00023E4C /* ReactionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0CC9F024C606EF00705CF9 /* ReactionEvents.swift */; }; C121E821274544AD00023E4C /* NotificationEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9CBD524C73689006E236C /* NotificationEvents.swift */; }; - C121E822274544AD00023E4C /* WebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F38247FFACB00EAF71D /* WebSocketEngine.swift */; }; - C121E823274544AD00023E4C /* URLSessionWebSocketEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F772489181200CDEB89 /* URLSessionWebSocketEngine.swift */; }; - C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9444247D3DD2001F1104 /* WebSocketClient.swift */; }; - C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A618E4424D19D510003D83C /* WebSocketPingController.swift */; }; C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */; }; C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8933D2025FFAB400054BBFF /* APIPathConvertible.swift */; }; C121E82C274544AD00023E4C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9442247D3DA7001F1104 /* APIClient.swift */; }; @@ -2159,7 +2147,6 @@ C121E865274544AE00023E4C /* MemberEventObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F63CC37224E592D30052844D /* MemberEventObserver.swift */; }; C121E866274544AE00023E4C /* MessageSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9448247D5211001F1104 /* MessageSender.swift */; }; C121E868274544AF00023E4C /* MessageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F670B50E24FE6EA900003B1A /* MessageEditor.swift */; }; - C121E869274544AF00023E4C /* ConnectionRecoveryHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ED5F7325023EB4005D7327 /* ConnectionRecoveryHandler.swift */; }; C121E86B274544AF00023E4C /* AttachmentQueueUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E26D6D2580F34B00F55AB5 /* AttachmentQueueUploader.swift */; }; C121E86D274544AF00023E4C /* DatabaseContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C945D247D7283001F1104 /* DatabaseContainer.swift */; }; C121E86E274544AF00023E4C /* DatabaseSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792FCB4A24A3D52A000290C7 /* DatabaseSession.swift */; }; @@ -2715,7 +2702,6 @@ F6D61D9B2510B3FC00EB0624 /* NSManagedObject+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D61D9A2510B3FC00EB0624 /* NSManagedObject+Extensions.swift */; }; F6D61D9D2510B57F00EB0624 /* NSManagedObject_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D61D9C2510B57F00EB0624 /* NSManagedObject_Tests.swift */; }; F6E5E3472627A372007FA51F /* CGRect+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E5E3462627A372007FA51F /* CGRect+Extensions.swift */; }; - F6ED5F7425023EB4005D7327 /* ConnectionRecoveryHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ED5F7325023EB4005D7327 /* ConnectionRecoveryHandler.swift */; }; F6ED5F76250278D7005D7327 /* SyncEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ED5F75250278D7005D7327 /* SyncEndpoint.swift */; }; F6ED5F7825027907005D7327 /* MissingEventsRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ED5F7725027907005D7327 /* MissingEventsRequestBody.swift */; }; F6ED5F7A2502791F005D7327 /* MissingEventsPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ED5F792502791F005D7327 /* MissingEventsPayload.swift */; }; @@ -3473,14 +3459,12 @@ 79280F48248520B300CDEB89 /* EventDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDecoder.swift; sourceTree = ""; }; 79280F4A248523C000CDEB89 /* ConnectionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionEvents.swift; sourceTree = ""; }; 79280F4E2485308100CDEB89 /* DataController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataController.swift; sourceTree = ""; }; - 79280F772489181200CDEB89 /* URLSessionWebSocketEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionWebSocketEngine.swift; sourceTree = ""; }; 79280F7B24891B0F00CDEB89 /* WebSocketEngine_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketEngine_Mock.swift; sourceTree = ""; }; 792921C424C0479700116BBB /* ChannelListUpdater_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListUpdater_Tests.swift; sourceTree = ""; }; 792921C624C047DD00116BBB /* APIClient_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient_Spy.swift; sourceTree = ""; }; 792921C824C056F400116BBB /* ChannelListController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListController_Tests.swift; sourceTree = ""; }; 792A4F1A247FE84900EAF71D /* ChannelListUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListUpdater.swift; sourceTree = ""; }; 792A4F1C247FEA2200EAF71D /* ChannelListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListController.swift; sourceTree = ""; }; - 792A4F38247FFACB00EAF71D /* WebSocketEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketEngine.swift; sourceTree = ""; }; 792A4F3D247FFDE700EAF71D /* Codable+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = ""; }; 792A4F422480107A00EAF71D /* ChannelQuery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelQuery.swift; sourceTree = ""; }; 792A4F432480107A00EAF71D /* Filter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; @@ -3608,7 +3592,6 @@ 799C942F247D2FB9001F1104 /* ChatClient_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatClient_Tests.swift; sourceTree = ""; }; 799C9431247D2FB9001F1104 /* ChatMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; 799C9442247D3DA7001F1104 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; - 799C9444247D3DD2001F1104 /* WebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketClient.swift; sourceTree = ""; }; 799C9446247D50F3001F1104 /* Worker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Worker.swift; sourceTree = ""; }; 799C9448247D5211001F1104 /* MessageSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSender.swift; sourceTree = ""; }; 799C944B247D5766001F1104 /* ChannelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelController.swift; sourceTree = ""; }; @@ -3619,7 +3602,6 @@ 799C947B247E6051001F1104 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListQuery_Tests.swift; sourceTree = ""; }; 79A0E9AC2498BD0C00E9BD50 /* ChatClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatClient.swift; sourceTree = ""; }; - 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketClient_Tests.swift; sourceTree = ""; }; 79A0E9B82498C31300E9BD50 /* TypingStartCleanupMiddleware_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingStartCleanupMiddleware_Tests.swift; sourceTree = ""; }; 79A0E9B92498C31300E9BD50 /* TypingStartCleanupMiddleware.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingStartCleanupMiddleware.swift; sourceTree = ""; }; 79A0E9BD2498C33100E9BD50 /* TypingEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingEvent.swift; sourceTree = ""; }; @@ -4025,7 +4007,6 @@ 88F896F82541AC0900DE517D /* FlagMessagePayload+CustomExtraData.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "FlagMessagePayload+CustomExtraData.json"; sourceTree = ""; }; 8A0175EF2501174000570345 /* TypingEventsSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingEventsSender.swift; sourceTree = ""; }; 8A0175F325013B6400570345 /* TypingEventSender_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingEventSender_Tests.swift; sourceTree = ""; }; - 8A08C6A524D437DF00DEF995 /* WebSocketPingController_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketPingController_Tests.swift; sourceTree = ""; }; 8A0C3BBB24C0947400CAFD19 /* UserEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEvents.swift; sourceTree = ""; }; 8A0C3BBD24C0AC6400CAFD19 /* UserPresence.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = UserPresence.json; sourceTree = ""; }; 8A0C3BBE24C0AC7800CAFD19 /* UserUnbanned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = UserUnbanned.json; sourceTree = ""; }; @@ -4064,7 +4045,6 @@ 8A0D64AA24E57BF20017A3C0 /* ChannelListPayload_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelListPayload_Tests.swift; sourceTree = ""; }; 8A0D64AD24E5853F0017A3C0 /* DataController_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataController_Tests.swift; sourceTree = ""; }; 8A5D3EF824AF749200E2FE35 /* ChannelId_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelId_Tests.swift; sourceTree = ""; }; - 8A618E4424D19D510003D83C /* WebSocketPingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketPingController.swift; sourceTree = ""; }; 8A62704D24B8660A0040BFD6 /* EventType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventType.swift; sourceTree = ""; }; 8A62704F24B867190040BFD6 /* EventPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventPayload.swift; sourceTree = ""; }; 8A62705B24BE2BC00040BFD6 /* TypingEvent_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingEvent_Tests.swift; sourceTree = ""; }; @@ -4171,7 +4151,6 @@ A3813B4B2825C8030076E838 /* CustomChatMessageListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomChatMessageListRouter.swift; sourceTree = ""; }; A3813B4D2825C8A30076E838 /* ThreadVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadVC.swift; sourceTree = ""; }; A382131D2805C8AC0068D30E /* TestsEnvironmentSetup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestsEnvironmentSetup.swift; sourceTree = ""; }; - A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionRecoveryHandler_Tests.swift; sourceTree = ""; }; A396B752260CCE7400D8D15B /* TitleContainerView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleContainerView_Tests.swift; sourceTree = ""; }; A39A8AE6263825F4003453D9 /* ChatMessageLayoutOptionsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageLayoutOptionsResolver.swift; sourceTree = ""; }; A39B040A27F196F200D6B18A /* StreamChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamChatUITests.swift; sourceTree = ""; }; @@ -4253,7 +4232,6 @@ A3C7BAD627E4E51600BBF4FA /* Calendar+GMT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+GMT.swift"; sourceTree = ""; }; A3C7BAD827E4E6AB00BBF4FA /* WebSocketPingController_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketPingController_Mock.swift; sourceTree = ""; }; A3C7BADA27E4E82100BBF4FA /* WebSocketEngineError+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebSocketEngineError+Equatable.swift"; sourceTree = ""; }; - A3C7BADC27E4E86400BBF4FA /* WebSocketPingController_Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketPingController_Delegate.swift; sourceTree = ""; }; A3C7BADE27E4E8C000BBF4FA /* TypingEventDTO+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TypingEventDTO+Unique.swift"; sourceTree = ""; }; A3C7BAE127E4E95800BBF4FA /* CleanUpTypingEvent+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CleanUpTypingEvent+Equatable.swift"; sourceTree = ""; }; A3C7BAE427E4EABC00BBF4FA /* ChannelEvents_IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEvents_IntegrationTests.swift; sourceTree = ""; }; @@ -4957,7 +4935,6 @@ F6D61D9A2510B3FC00EB0624 /* NSManagedObject+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject+Extensions.swift"; sourceTree = ""; }; F6D61D9C2510B57F00EB0624 /* NSManagedObject_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObject_Tests.swift; sourceTree = ""; }; F6E5E3462627A372007FA51F /* CGRect+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Extensions.swift"; sourceTree = ""; }; - F6ED5F7325023EB4005D7327 /* ConnectionRecoveryHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRecoveryHandler.swift; sourceTree = ""; }; F6ED5F75250278D7005D7327 /* SyncEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEndpoint.swift; sourceTree = ""; }; F6ED5F7725027907005D7327 /* MissingEventsRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingEventsRequestBody.swift; sourceTree = ""; }; F6ED5F792502791F005D7327 /* MissingEventsPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingEventsPayload.swift; sourceTree = ""; }; @@ -5866,22 +5843,12 @@ children = ( AD8E75E52E04953C00AE0F70 /* ActiveLiveLocationsEndTimeTracker.swift */, 88E26D6D2580F34B00F55AB5 /* AttachmentQueueUploader.swift */, - F6ED5F7325023EB4005D7327 /* ConnectionRecoveryHandler.swift */, F670B50E24FE6EA900003B1A /* MessageEditor.swift */, 799C9448247D5211001F1104 /* MessageSender.swift */, ); path = Background; sourceTree = ""; }; - 79280F76248917EB00CDEB89 /* Engine */ = { - isa = PBXGroup; - children = ( - 79280F772489181200CDEB89 /* URLSessionWebSocketEngine.swift */, - 792A4F38247FFACB00EAF71D /* WebSocketEngine.swift */, - ); - path = Engine; - sourceTree = ""; - }; 792A4F18247EA97000EAF71D /* DTOs */ = { isa = PBXGroup; children = ( @@ -6152,10 +6119,7 @@ 799C9426247D2FB9001F1104 /* WebSocketClient */ = { isa = PBXGroup; children = ( - 799C9444247D3DD2001F1104 /* WebSocketClient.swift */, 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */, - 8A618E4424D19D510003D83C /* WebSocketPingController.swift */, - 79280F76248917EB00CDEB89 /* Engine */, 796610B7248E64EC00761629 /* EventMiddlewares */, 79280F402484F4DD00CDEB89 /* Events */, ); @@ -7254,9 +7218,7 @@ A364D08C27D0BD7B0029857A /* WebSocketClient */ = { isa = PBXGroup; children = ( - 79A0E9AE2498BFD800E9BD50 /* WebSocketClient_Tests.swift */, 430156F126B4523A0006E7EA /* WebSocketConnectPayload_Tests.swift */, - 8A08C6A524D437DF00DEF995 /* WebSocketPingController_Tests.swift */, A364D08D27D0BD8E0029857A /* EventMiddlewares */, A364D08E27D0BDB20029857A /* Events */, ); @@ -7476,7 +7438,6 @@ children = ( ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */, 88F7692A25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift */, - A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */, F61D7C3624FFE17200188A0E /* MessageEditor_Tests.swift */, 791C0B6224EEBDF40013CA2F /* MessageSender_Tests.swift */, ); @@ -8320,7 +8281,6 @@ A3C7BAC627E4DBF900BBF4FA /* MessageSearchController_Delegate.swift */, A3C7BABA27E4D97500BBF4FA /* UserController_Delegate.swift */, A3C7BACC27E4DD9000BBF4FA /* UserListController_Delegate.swift */, - A3C7BADC27E4E86400BBF4FA /* WebSocketPingController_Delegate.swift */, 82E655342B06751D00D64906 /* QueueAwareDelegate.swift */, ); path = QueueAware; @@ -11477,7 +11437,6 @@ A3D9D68627EDE3B900725066 /* XCTestCase+TestImages.swift in Sources */, A344077727D753530044F150 /* ChatMessage_Mock.swift in Sources */, 84F373EC280D803E0081E8BA /* TestChannelObserver.swift in Sources */, - A311B43827E8BC8400CFCF6D /* WebSocketPingController_Delegate.swift in Sources */, A311B43227E8BC8400CFCF6D /* TestChannelListObserver.swift in Sources */, A3C3BC7927E8AA9400224761 /* AnyEncodable+Equatable.swift in Sources */, 82E6553F2B06798100D64906 /* AssertJSONEqual.swift in Sources */, @@ -11765,7 +11724,6 @@ 799C9449247D5211001F1104 /* MessageSender.swift in Sources */, 792A4F4D248011E500EAF71D /* ChannelListQuery.swift in Sources */, 882C5746252C6FDF00E60C44 /* ChannelMemberListQuery.swift in Sources */, - 799C9445247D3DD2001F1104 /* WebSocketClient.swift in Sources */, AD6E32A12BBC50110073831B /* ThreadListQuery.swift in Sources */, AD8258A32BD2939500B9ED74 /* MessageReactionGroup.swift in Sources */, C15C8838286C7BF300E6A72C /* BackgroundListDatabaseObserver.swift in Sources */, @@ -11880,7 +11838,6 @@ C143789027BC03EE00E23965 /* EndpointPath.swift in Sources */, C10C7552299D1D67008C8F78 /* ChannelRepository.swift in Sources */, 790A4C45252DD4F1001F4A23 /* DevicePayloads.swift in Sources */, - 792A4F39247FFACB00EAF71D /* WebSocketEngine.swift in Sources */, 4F97F26D2BA858E9001C4D66 /* UserSearch.swift in Sources */, 79280F422484F4EC00CDEB89 /* Event.swift in Sources */, C14D27B62869EEE40063F6F2 /* Sequence+CompactMapLoggingError.swift in Sources */, @@ -11941,7 +11898,6 @@ 88E26D5E2580E92000F55AB5 /* AttachmentId.swift in Sources */, AD94906F2BF68BB200E69224 /* ThreadListController+Combine.swift in Sources */, 799C9447247D50F3001F1104 /* Worker.swift in Sources */, - 79280F782489181200CDEB89 /* URLSessionWebSocketEngine.swift in Sources */, 79682C4624BC9DAF0071578E /* ChannelUpdater.swift in Sources */, 8413D2EF2BDD9429005ADA4E /* PollVoteListController.swift in Sources */, 792A4F482480107A00EAF71D /* Pagination.swift in Sources */, @@ -12015,7 +11971,6 @@ AD37D7C72BC98A4400800D8C /* ThreadParticipantDTO.swift in Sources */, DA4EE5B8252B69E300CB26D4 /* UserListController+Combine.swift in Sources */, 4FE56B8D2D5DFE4600589F9A /* MarkdownParser.swift in Sources */, - F6ED5F7425023EB4005D7327 /* ConnectionRecoveryHandler.swift in Sources */, 79877A0B2498E4BC00015F8B /* Member.swift in Sources */, 84ABB015269F0A84003A4585 /* EventsController+Combine.swift in Sources */, 841BAA012BCE9394000C73E4 /* UpdatePollRequestBody.swift in Sources */, @@ -12041,7 +11996,6 @@ 4F877D3D2D019ED600CB66EC /* ChannelArchivingScope.swift in Sources */, F6CCA24D251235F7004C1859 /* UserTypingStateUpdaterMiddleware.swift in Sources */, 4FD2BE502B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */, - 8A618E4524D19D510003D83C /* WebSocketPingController.swift in Sources */, 8A62704E24B8660A0040BFD6 /* EventType.swift in Sources */, C135A1CB28F45F6B0058EFB6 /* AuthenticationRepository.swift in Sources */, CFE616BB28348AC500AE2ABF /* StreamTimer.swift in Sources */, @@ -12222,7 +12176,6 @@ 799F611B2530B62C007F218C /* ChannelListQuery_Tests.swift in Sources */, AD25F7512E86EB5700F16B14 /* PushPreferencePayload_Tests.swift in Sources */, 792921C524C0479700116BBB /* ChannelListUpdater_Tests.swift in Sources */, - A3F65E3727EB7161003F6256 /* WebSocketClient_Tests.swift in Sources */, F61D7C3524FFA6FD00188A0E /* MessageUpdater_Tests.swift in Sources */, C143789527BE65AE00E23965 /* QueuedRequestDTO_Tests.swift in Sources */, C122B8812A02645200D27F41 /* ChannelReadPayload_Tests.swift in Sources */, @@ -12272,7 +12225,6 @@ 88AA928E254735CF00BFA0C3 /* MessageReactionDTO_Tests.swift in Sources */, 889B00E5252C972C007709A8 /* ChannelMemberListQuery_Tests.swift in Sources */, 84C11BE127FB2C2B00000A9E /* ChannelReadDTO_Tests.swift in Sources */, - A3960E0D27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift in Sources */, 7931818E24FD4275002F8C84 /* ChannelListController+Combine_Tests.swift in Sources */, 88EA9B0625472430007EE76B /* MessageReactionPayload_Tests.swift in Sources */, 79B8B649285B5ADD0059FB2D /* ChannelListSortingKey_Tests.swift in Sources */, @@ -12315,7 +12267,6 @@ 4FCCACE42BC939EB009D23E1 /* MemberList_Tests.swift in Sources */, C174E0F9284DFD660040B936 /* IdentifiablePayload_Tests.swift in Sources */, 4F072F032BC008D9006A66CA /* StateLayerDatabaseObserver_Tests.swift in Sources */, - 8A08C6A624D437DF00DEF995 /* WebSocketPingController_Tests.swift in Sources */, 4042968C29FAD03B0089126D /* AudioSamplesPercentageTransformer_Tests.swift in Sources */, 8A62706C24BF3DBC0040BFD6 /* ChannelEvents_Tests.swift in Sources */, 790882A22546D95F00896F03 /* FlagMessagePayload_Tests.swift in Sources */, @@ -12623,14 +12574,10 @@ C121E821274544AD00023E4C /* NotificationEvents.swift in Sources */, 4042968729FACA420089126D /* AudioSamplesExtractor.swift in Sources */, 841BAA4F2BD1CD76000C73E4 /* PollOptionDTO.swift in Sources */, - C121E822274544AD00023E4C /* WebSocketEngine.swift in Sources */, C1B0B38427BFC08900C8207D /* EndpointPath+OfflineRequest.swift in Sources */, - C121E823274544AD00023E4C /* URLSessionWebSocketEngine.swift in Sources */, ADB2087F2D849184003F1059 /* MessageReminderListQuery.swift in Sources */, 79D5CDD527EA1BE300BE7D8B /* MessageTranslationsPayload.swift in Sources */, - C121E826274544AD00023E4C /* WebSocketClient.swift in Sources */, C135A1CC28F45F6B0058EFB6 /* AuthenticationRepository.swift in Sources */, - C121E827274544AD00023E4C /* WebSocketPingController.swift in Sources */, C121E829274544AD00023E4C /* WebSocketConnectPayload.swift in Sources */, 4F8E53172B7F58C1008C0F9F /* ChatClient+Factory.swift in Sources */, ADA83B402D974DCC003B3928 /* MessageReminderListController.swift in Sources */, @@ -12742,7 +12689,6 @@ C121E868274544AF00023E4C /* MessageEditor.swift in Sources */, AD78F9F028EC719200BC0FCE /* ChannelTruncateRequestPayload.swift in Sources */, 4FFB5EA12BA0507900F0454F /* Collection+Extensions.swift in Sources */, - C121E869274544AF00023E4C /* ConnectionRecoveryHandler.swift in Sources */, C121E86B274544AF00023E4C /* AttachmentQueueUploader.swift in Sources */, 841BAA552BD26136000C73E4 /* PollOption.swift in Sources */, 4042968A29FACA6A0089126D /* AudioValuePercentageNormaliser.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 52543b80ffc..8b7e05a92e1 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamChat +@testable import StreamCore import XCTest final class ChatClient_Mock: ChatClient, @unchecked Sendable { @@ -29,7 +30,7 @@ final class ChatClient_Mock: ChatClient, @unchecked Sendable { var mockedEventNotificationCenter: EventNotificationCenter_Mock? - override var eventNotificationCenter: EventNotificationCenter { + override var eventNotificationCenter: PersistentEventNotificationCenter { mockedEventNotificationCenter ?? super.eventNotificationCenter } @@ -151,9 +152,8 @@ extension ChatClient { webSocketClientBuilder: { WebSocketClient_Mock( sessionConfiguration: $0, - requestEncoder: $1, - eventDecoder: $2, - eventNotificationCenter: $3 + eventDecoder: $1, + eventNotificationCenter: $2 ) }, databaseContainerBuilder: { @@ -302,9 +302,8 @@ extension ChatClient.Environment { webSocketClientBuilder: { WebSocketClient_Mock( sessionConfiguration: $0, - requestEncoder: $1, - eventDecoder: $2, - eventNotificationCenter: $3 + eventDecoder: $1, + eventNotificationCenter: $2 ) }, databaseContainerBuilder: { @@ -323,7 +322,7 @@ extension ChatClient.Environment { EventDecoder() }, notificationCenterBuilder: { - EventNotificationCenter(database: $0, manualEventHandler: $1) + PersistentEventNotificationCenter(database: $0, manualEventHandler: $1) }, authenticationRepositoryBuilder: { AuthenticationRepository_Mock( @@ -390,10 +389,11 @@ extension ChatClient.Environment { return WebSocketClient( sessionConfiguration: $0, - requestEncoder: $1, - eventDecoder: $2, - eventNotificationCenter: $3, - environment: webSocketEnvironment + eventDecoder: $1, + eventNotificationCenter: $2, + webSocketClientType: .coordinator, + environment: webSocketEnvironment, + connectRequest: nil ) } ) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift index bde581d5d32..a1c6d35ed86 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift @@ -37,6 +37,7 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy, @unchecked Sen self.init( isClientInActiveMode: true, syncRepository: SyncRepository_Mock(), + webSocketEncoder: DefaultRequestEncoder(baseURL: .unique(), apiKey: .init(.unique)), webSocketClient: WebSocketClient_Mock(), apiClient: APIClient_Spy(), timerType: DefaultTimer.self @@ -47,16 +48,18 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy, @unchecked Sen self.init( isClientInActiveMode: client.config.isClientInActiveMode, syncRepository: client.syncRepository, + webSocketEncoder: client.webSocketEncoder, webSocketClient: client.webSocketClient, apiClient: client.apiClient, timerType: DefaultTimer.self ) } - override init(isClientInActiveMode: Bool, syncRepository: SyncRepository, webSocketClient: WebSocketClient?, apiClient: APIClient, timerType: TimerScheduling.Type) { + override init(isClientInActiveMode: Bool, syncRepository: SyncRepository, webSocketEncoder: RequestEncoder?, webSocketClient: WebSocketClient?, apiClient: APIClient, timerType: TimerScheduling.Type) { super.init( isClientInActiveMode: isClientInActiveMode, syncRepository: syncRepository, + webSocketEncoder: webSocketEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: timerType diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift index 3954a169e65..934ba134197 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift @@ -4,13 +4,13 @@ import Foundation @testable import StreamChat +@testable import StreamCore /// Mock implementation of `WebSocketClient`. final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { let init_sessionConfiguration: URLSessionConfiguration - let init_requestEncoder: RequestEncoder let init_eventDecoder: AnyEventDecoder - let init_eventNotificationCenter: EventNotificationCenter + let init_eventNotificationCenter: PersistentEventNotificationCenter let init_environment: WebSocketClient.Environment var connect_calledCounter = 0 @@ -21,24 +21,22 @@ final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { var disconnect_called: Bool { disconnect_calledCounter > 0 } var disconnect_completion: (() -> Void)? - var mockedConnectionState: WebSocketConnectionState? - - override var connectionState: WebSocketConnectionState { - mockedConnectionState ?? super.connectionState + var mockedConnectionState: WebSocketConnectionState { + get { connectionState } + set { connectionState = newValue } } init( sessionConfiguration: URLSessionConfiguration = .ephemeral, - requestEncoder: RequestEncoder = DefaultRequestEncoder(baseURL: .unique(), apiKey: .init(.unique)), eventDecoder: AnyEventDecoder = EventDecoder(), - eventNotificationCenter: EventNotificationCenter = EventNotificationCenter_Mock(database: DatabaseContainer_Spy()), + eventNotificationCenter: PersistentEventNotificationCenter = EventNotificationCenter_Mock(database: DatabaseContainer_Spy()), pingController: WebSocketPingController? = nil, webSocketEngine: WebSocketEngine? = nil, eventBatcher: EventBatcher? = nil ) { var environment = WebSocketClient.Environment.mock if let pingController = pingController { - environment.createPingController = { _, _ in pingController } + environment.createPingController = { _, _, _ in pingController } } if let webSocketEngine = webSocketEngine { @@ -50,17 +48,17 @@ final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { } init_sessionConfiguration = sessionConfiguration - init_requestEncoder = requestEncoder init_eventDecoder = eventDecoder init_eventNotificationCenter = eventNotificationCenter init_environment = environment super.init( sessionConfiguration: sessionConfiguration, - requestEncoder: requestEncoder, eventDecoder: eventDecoder, eventNotificationCenter: eventNotificationCenter, - environment: environment + webSocketClientType: .coordinator, + environment: environment, + connectRequest: nil ) } @@ -68,9 +66,10 @@ final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { connect_calledCounter += 1 } - override func disconnect( + override public func disconnect( + code: URLSessionWebSocketTask.CloseCode = .normalClosure, source: WebSocketConnectionState.DisconnectionSource = .userInitiated, - completion: @escaping () -> Void + completion: @Sendable @escaping () -> Void ) { disconnect_calledCounter += 1 disconnect_source = source diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift index f71556cd900..fb175c506ea 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift @@ -38,6 +38,14 @@ final class WebSocketEngine_Mock: WebSocketEngine, @unchecked Sendable { func disconnect() { disconnect_calledCount += 1 } + + func disconnect(with code: URLSessionWebSocketTask.CloseCode) { + disconnect_calledCount += 1 + } + + func send(message: any SendableEvent) {} + + func send(jsonMessage: any Codable) {} func sendPing() { sendPing_calledCount += 1 @@ -56,7 +64,7 @@ final class WebSocketEngine_Mock: WebSocketEngine, @unchecked Sendable { } func simulateMessageReceived(_ data: Data) { - delegate?.webSocketDidReceiveMessage(String(data: data, encoding: .utf8)!) + delegate?.webSocketDidReceiveMessage(data) } func simulatePong() { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketPingController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketPingController_Mock.swift index b3432d73b0d..cde2703f751 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketPingController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketPingController_Mock.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamChat +@testable import StreamCore import XCTest final class WebSocketPingController_Mock: WebSocketPingController, @unchecked Sendable { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift index 97e951eb4d0..99d9c4f5fab 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift @@ -6,9 +6,9 @@ import XCTest /// Mock implementation of `ConnectionRecoveryHandler` -final class ConnectionRecoveryHandler_Mock: ConnectionRecoveryHandler { - var startCallCount = 0 - var stopCallCount = 0 +final class ConnectionRecoveryHandler_Mock: ConnectionRecoveryHandler, @unchecked Sendable { + @Atomic var startCallCount = 0 + @Atomic var stopCallCount = 0 func start() { startCallCount += 1 diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift index d2149d4beb9..696434864e4 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift @@ -6,7 +6,7 @@ import Foundation @testable import StreamChat /// Mock implementation of `EventNotificationCenter` -final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Sendable { +final class EventNotificationCenter_Mock: PersistentEventNotificationCenter, @unchecked Sendable { override var newMessageIds: Set { newMessageIdsMock ?? super.newMessageIds } diff --git a/TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift b/TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift deleted file mode 100644 index c0dd916c15f..00000000000 --- a/TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamChat - -// A concrete `WebSocketPingControllerDelegate` implementation allowing capturing the delegate calls -final class WebSocketPingController_Delegate: WebSocketPingControllerDelegate { - var sendPing_calledCount = 0 - var disconnectOnNoPongReceived_calledCount = 0 - - func sendPing() { - sendPing_calledCount += 1 - } - - func disconnectOnNoPongReceived() { - disconnectOnNoPongReceived_calledCount += 1 - } -} diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index 16f14140396..416b3a5c00a 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -86,7 +86,14 @@ final class ChatClient_Tests: XCTestCase { // Create env object with custom database builder var env = ChatClient.Environment() env.connectionRepositoryBuilder = { - ConnectionRepository_Mock(isClientInActiveMode: $0, syncRepository: $1, webSocketClient: $2, apiClient: $3, timerType: $4) + ConnectionRepository_Mock( + isClientInActiveMode: $0, + syncRepository: $1, + webSocketEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 + ) } env.databaseContainerBuilder = { [config] kind, clientConfig in XCTAssertEqual( @@ -116,7 +123,14 @@ final class ChatClient_Tests: XCTestCase { // Create env object with custom database builder var env = ChatClient.Environment() env.connectionRepositoryBuilder = { - ConnectionRepository_Mock(isClientInActiveMode: $0, syncRepository: $1, webSocketClient: $2, apiClient: $3, timerType: $4) + ConnectionRepository_Mock( + isClientInActiveMode: $0, + syncRepository: $1, + webSocketEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 + ) } env.databaseContainerBuilder = { kind, _ in XCTAssertEqual(kind, .inMemory) @@ -141,7 +155,14 @@ final class ChatClient_Tests: XCTestCase { // Create env object and store all `kinds it's called with. var env = ChatClient.Environment() env.connectionRepositoryBuilder = { - ConnectionRepository_Mock(isClientInActiveMode: $0, syncRepository: $1, webSocketClient: $2, apiClient: $3, timerType: $4) + ConnectionRepository_Mock( + isClientInActiveMode: $0, + syncRepository: $1, + webSocketEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 + ) } env.databaseContainerBuilder = { kind, _ in XCTAssertEqual(.inMemory, kind) @@ -175,7 +196,6 @@ final class ChatClient_Tests: XCTestCase { let webSocket = testEnv.webSocketClient assertMandatoryHeaderFields(webSocket?.init_sessionConfiguration) XCTAssertEqual(webSocket?.init_sessionConfiguration.waitsForConnectivity, false) - XCTAssert(webSocket?.init_requestEncoder is RequestEncoder_Spy) XCTAssert(webSocket?.init_eventNotificationCenter.database === client.databaseContainer) XCTAssertNotNil(webSocket?.init_eventDecoder) @@ -183,7 +203,7 @@ final class ChatClient_Tests: XCTestCase { XCTAssert(webSocket?.init_eventNotificationCenter.middlewares[0] is EventDataProcessorMiddleware) // Assert Client sets itself as delegate for the request encoder - XCTAssert(webSocket?.init_requestEncoder.connectionDetailsProviderDelegate === client) + XCTAssert(client.webSocketEncoder?.connectionDetailsProviderDelegate === client) } func test_webSocketClient_hasAllMandatoryMiddlewares() throws { @@ -473,34 +493,29 @@ final class ChatClient_Tests: XCTestCase { try chatClient.databaseContainer.createCurrentUser(id: currentUserId) AssertAsync.canBeReleased(&chatClient) - - // Take main then background queue. - for queue in [DispatchQueue.main, DispatchQueue.global()] { - let error: Error? = try waitFor { completion in - // Dispatch creating a chat-client to specific queue. - queue.async { - // Create a `ChatClient` instance with the same config - // to access the storage with exited current user. - let chatClient = ChatClient(config: config) - chatClient.connectUser(userInfo: .init(id: currentUserId), token: .unique(userId: currentUserId)) - - let expectedWebSocketEndpoint = AnyEndpoint( - .webSocketConnect(userInfo: UserInfo(id: currentUserId)) - ) - - // 1. Check `currentUserId` is fetched synchronously - // 2. `webSocket` has correct connect endpoint - if chatClient.currentUserId == currentUserId, - chatClient.webSocketClient?.connectEndpoint.map(AnyEndpoint.init) == expectedWebSocketEndpoint { - completion(nil) - } else { - completion(TestError()) - } + + let queueAndExpectation: [(DispatchQueue, XCTestExpectation)] = [ + (.main, XCTestExpectation(description: "main")), + (.global(), XCTestExpectation(description: "global")) + ] + for (queue, expectation) in queueAndExpectation { + // Dispatch creating a chat-client to specific queue. + queue.async { + // Create a `ChatClient` instance with the same config + // to access the storage with exited current user. + let chatClient = ChatClient(config: config) + chatClient.connectUser(userInfo: .init(id: currentUserId), token: .unique(userId: currentUserId)) + + let expectedWebSocketEndpoint = AnyEndpoint(.webSocketConnect(userInfo: UserInfo(id: currentUserId))) + // 1. Check `currentUserId` is fetched synchronously + // 2. `webSocket` has correct connect endpoint + if chatClient.currentUserId == currentUserId, + chatClient.connectionRepository.webSocketConnectEndpoint.value.map(AnyEndpoint.init) == expectedWebSocketEndpoint { + expectation.fulfill() } } - - XCTAssertNil(error) } + wait(for: queueAndExpectation.map({ $1 }), timeout: defaultTimeout) } // MARK: - Connect Token Provider @@ -992,7 +1007,7 @@ private class TestEnvironment { @Atomic var eventDecoder: EventDecoder? - @Atomic var notificationCenter: EventNotificationCenter? + @Atomic var notificationCenter: PersistentEventNotificationCenter? @Atomic var connectionRepository: ConnectionRepository_Mock? @@ -1016,9 +1031,8 @@ private class TestEnvironment { webSocketClientBuilder: { self.webSocketClient = WebSocketClient_Mock( sessionConfiguration: $0, - requestEncoder: $1, - eventDecoder: $2, - eventNotificationCenter: $3 + eventDecoder: $1, + eventNotificationCenter: $2 ) return self.webSocketClient! }, @@ -1060,7 +1074,14 @@ private class TestEnvironment { }, monitor: InternetConnectionMonitor_Mock(), connectionRepositoryBuilder: { - self.connectionRepository = ConnectionRepository_Mock(isClientInActiveMode: $0, syncRepository: $1, webSocketClient: $2, apiClient: $3, timerType: $4) + self.connectionRepository = ConnectionRepository_Mock( + isClientInActiveMode: $0, + syncRepository: $1, + webSocketEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 + ) return self.connectionRepository! }, backgroundTaskSchedulerBuilder: { @@ -1068,7 +1089,7 @@ private class TestEnvironment { return self.backgroundTaskScheduler! }, timerType: VirtualTimeTimer.self, - connectionRecoveryHandlerBuilder: { _, _, _, _, _, _ in + connectionRecoveryHandlerBuilder: { _, _, _, _, _ in ConnectionRecoveryHandler_Mock() }, authenticationRepositoryBuilder: { diff --git a/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift b/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift index 3437a9e1053..cd3243ee00f 100644 --- a/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift @@ -5,6 +5,7 @@ import CoreData @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class ChatConnectionController_Tests: XCTestCase { @@ -126,3 +127,10 @@ final class ChatConnectionController_Tests: XCTestCase { XCTAssertCall(ConnectionRepository_Mock.Signature.disconnect, on: connectionRepository) } } + +extension WebSocketClient { + /// Simulates connection status change + func simulateConnectionStatus(_ status: WebSocketConnectionState) { + connectionState = status + } +} diff --git a/Tests/StreamChatTests/Controllers/EventsController/EventsController+Combine_Tests.swift b/Tests/StreamChatTests/Controllers/EventsController/EventsController+Combine_Tests.swift index 2ef70d1b499..c5cb720a669 100644 --- a/Tests/StreamChatTests/Controllers/EventsController/EventsController+Combine_Tests.swift +++ b/Tests/StreamChatTests/Controllers/EventsController/EventsController+Combine_Tests.swift @@ -9,7 +9,7 @@ import XCTest final class EventsController_Combine_Tests: iOS13TestCase { var controller: EventsController! - var notificationCenter: EventNotificationCenter! + var notificationCenter: PersistentEventNotificationCenter! var cancellables: Set! // MARK: - Setup diff --git a/Tests/StreamChatTests/Controllers/EventsController/EventsController+SwiftUI_Tests.swift b/Tests/StreamChatTests/Controllers/EventsController/EventsController+SwiftUI_Tests.swift index 791595efb47..c9ee7d3059d 100644 --- a/Tests/StreamChatTests/Controllers/EventsController/EventsController+SwiftUI_Tests.swift +++ b/Tests/StreamChatTests/Controllers/EventsController/EventsController+SwiftUI_Tests.swift @@ -8,7 +8,7 @@ import XCTest final class EventsController_SwiftUI_Tests: iOS13TestCase { var controller: EventsController! - var notificationCenter: EventNotificationCenter! + var notificationCenter: PersistentEventNotificationCenter! // MARK: - Setup diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index 58f24abfdf5..d298f08a8c5 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -9,17 +9,20 @@ import XCTest final class ConnectionRepository_Tests: XCTestCase { private var repository: ConnectionRepository! private var webSocketClient: WebSocketClient_Mock! + private var webSocketRequestEncoder: DefaultRequestEncoder! private var syncRepository: SyncRepository_Mock! private var apiClient: APIClient_Spy! override func setUp() { super.setUp() webSocketClient = WebSocketClient_Mock() + webSocketRequestEncoder = DefaultRequestEncoder(baseURL: .unique(), apiKey: .init(.unique)) apiClient = APIClient_Spy() syncRepository = SyncRepository_Mock() repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -46,6 +49,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -157,6 +161,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -196,12 +201,12 @@ final class ConnectionRepository_Tests: XCTestCase { let tokenUserId = "123-token-userId" let token = Token(rawValue: "", userId: tokenUserId, expiration: nil) - XCTAssertNil(webSocketClient.connectEndpoint) + XCTAssertNil(repository.webSocketConnectEndpoint.value) repository.updateWebSocketEndpoint(with: token, userInfo: nil) // UserInfo should take priority XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.value.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: tokenUserId) @@ -216,12 +221,12 @@ final class ConnectionRepository_Tests: XCTestCase { let tokenUserId = "123-token-userId" let token = Token(rawValue: "", userId: tokenUserId, expiration: nil) - XCTAssertNil(webSocketClient.connectEndpoint) + XCTAssertNil(repository.webSocketConnectEndpoint.value) repository.updateWebSocketEndpoint(with: token, userInfo: userInfo) // UserInfo should take priority XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.value.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: userInfoUserId) @@ -232,11 +237,11 @@ final class ConnectionRepository_Tests: XCTestCase { func test_updateWebSocketEndpointWithUserId() throws { let userId = "123-userId" - XCTAssertNil(webSocketClient.connectEndpoint) + XCTAssertNil(repository.webSocketConnectEndpoint.value) repository.updateWebSocketEndpoint(with: userId) XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.value.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: userId) @@ -302,6 +307,7 @@ final class ConnectionRepository_Tests: XCTestCase { let repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -342,6 +348,7 @@ final class ConnectionRepository_Tests: XCTestCase { let repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -545,6 +552,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -561,6 +569,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift deleted file mode 100644 index b0c2cd93fc6..00000000000 --- a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift +++ /dev/null @@ -1,550 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import CoreData -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class WebSocketClient_Tests: XCTestCase { - // The longest time WebSocket waits to reconnect. - let maxReconnectTimeout: VirtualTime.Seconds = 25 - - var webSocketClient: WebSocketClient! - - var time: VirtualTime! - var endpoint: Endpoint! - private var decoder: EventDecoder_Mock! - var engine: WebSocketEngine_Mock? { webSocketClient.engine as? WebSocketEngine_Mock } - var connectionId: String! - var user: ChatUser! - var requestEncoder: RequestEncoder_Spy! - var pingController: WebSocketPingController_Mock { webSocketClient.pingController as! WebSocketPingController_Mock } - var eventsBatcher: EventBatcher_Mock { webSocketClient.eventsBatcher as! EventBatcher_Mock } - - var eventNotificationCenter: EventNotificationCenter_Mock! - private var eventNotificationCenterMiddleware: EventMiddleware_Mock! - - var database: DatabaseContainer! - - override func setUp() { - super.setUp() - - time = VirtualTime() - VirtualTimeTimer.time = time - - endpoint = .webSocketConnect( - userInfo: UserInfo(id: .unique) - ) - - decoder = EventDecoder_Mock() - - requestEncoder = RequestEncoder_Spy(baseURL: .unique(), apiKey: .init(.unique)) - - database = DatabaseContainer_Spy() - eventNotificationCenter = EventNotificationCenter_Mock(database: database) - eventNotificationCenterMiddleware = EventMiddleware_Mock() - eventNotificationCenter.add(middleware: eventNotificationCenterMiddleware) - - var environment = WebSocketClient.Environment.mock - environment.timerType = VirtualTimeTimer.self - - webSocketClient = WebSocketClient( - sessionConfiguration: .ephemeral, - requestEncoder: requestEncoder, - eventDecoder: decoder, - eventNotificationCenter: eventNotificationCenter, - environment: environment - ) - - connectionId = UUID().uuidString - user = .mock(id: "test_user_\(UUID().uuidString)") - } - - override func tearDown() { - AssertAsync.canBeReleased(&webSocketClient) - AssertAsync.canBeReleased(&eventNotificationCenter) - AssertAsync.canBeReleased(&eventNotificationCenterMiddleware) - AssertAsync.canBeReleased(&database) - - webSocketClient = nil - eventNotificationCenter = nil - eventNotificationCenterMiddleware = nil - database = nil - VirtualTimeTimer.invalidate() - time = nil - endpoint = nil - decoder = nil - connectionId = nil - user = nil - requestEncoder = nil - - super.tearDown() - } - - // MARK: - Setup - - func test_webSocketClient_isInstantiatedInCorrectState() { - XCTAssertNil(webSocketClient.connectEndpoint) - XCTAssertNil(webSocketClient.engine) - } - - func test_engine_isReused_ifRequestIsNotChanged() { - // Setup endpoint. - webSocketClient.connectEndpoint = endpoint - - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - // Save currently existed engine. - let oldEngine = webSocketClient.engine - // Disconnect the client. - webSocketClient.disconnect {} - - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - - // Assert engine is reused since the connect request is not changed. - XCTAssertTrue(oldEngine === webSocketClient.engine) - } - - func test_engine_isRecreated_ifRequestIsChanged() { - // Setup endpoint. - webSocketClient.connectEndpoint = endpoint - - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - // Save currently existed engine. - let oldEngine = webSocketClient.engine - // Disconnect the client. - webSocketClient.disconnect {} - - // Update request encode to provide different request. - requestEncoder.encodeRequest = .success(.init(url: .unique())) - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - - // Assert engine is recreated since the connect request is changed. - XCTAssertFalse(oldEngine === webSocketClient.engine) - } - - func test_engine_whenRequestEncoderFails_engineIsNil() { - // Setup endpoint. - webSocketClient.connectEndpoint = endpoint - - // Update request encode to provide different request. - requestEncoder.encodeRequest = .failure(ClientError("Dummy")) - - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - - // Assert engine is recreated since the connect request is changed. - XCTAssertNil(webSocketClient.engine) - } - - // MARK: - Connection tests - - func test_connectionFlow() { - assert(webSocketClient.connectionState == .initialized) - - // Simulate response from the encoder - let request = URLRequest(url: .unique()) - requestEncoder.encodeRequest = .success(request) - - // Call `connect`, it should change connection state and call `connect` on the engine - webSocketClient.connectEndpoint = endpoint - webSocketClient.connect() - XCTAssertEqual(webSocketClient.connectionState, .connecting) - - AssertAsync { - Assert.willBeEqual(self.engine!.request, request) - Assert.willBeEqual(self.engine!.connect_calledCount, 1) - } - - // Simulate the engine is connected and check the connection state is updated - engine!.simulateConnectionSuccess() - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate a health check event is received and the connection state is updated - decoder.decodedEvent = .success(HealthCheckEvent(connectionId: connectionId)) - engine!.simulateMessageReceived() - - AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) - } - - func test_callingConnect_whenAlreadyConnected_hasNoEffect() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) - assert(engine!.connect_calledCount == 1) - - // Call connect and assert it has no effect - webSocketClient.connect() - AssertAsync { - Assert.staysTrue(self.engine!.connect_calledCount == 1) - Assert.staysTrue(self.webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: self.connectionId))) - } - } - - func test_disconnect_callsEngine() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) - assert(engine!.disconnect_calledCount == 0) - - // Call `disconnect`, it should change connection state and call `disconnect` on the engine - let source: WebSocketConnectionState.DisconnectionSource = .userInitiated - webSocketClient.disconnect(source: source) {} - - // Assert disconnect is called - AssertAsync.willBeEqual(engine!.disconnect_calledCount, 1) - } - - func test_whenConnectedAndEngineDisconnectsWithServerError_itIsTreatedAsServerInitiatedDisconnect() { - // Simulate connection - test_connectionFlow() - - // Simulate the engine disconnecting with server error - let errorPayload = ErrorPayload( - code: .unique, - message: .unique, - statusCode: .unique - ) - let engineError = WebSocketEngineError( - reason: UUID().uuidString, - code: 0, - engineError: errorPayload - ) - engine!.simulateDisconnect(engineError) - - // Assert state is disconnected with `systemInitiated` source - XCTAssertEqual( - webSocketClient.connectionState, - .disconnected(source: .serverInitiated(error: ClientError.WebSocket(with: engineError))) - ) - } - - func test_disconnect_propagatesDisconnectionSource() { - // Simulate connection - test_connectionFlow() - - let testCases: [WebSocketConnectionState.DisconnectionSource] = [ - .userInitiated, - .systemInitiated, - .serverInitiated(error: nil), - .serverInitiated(error: .init(.unique)) - ] - - for source in testCases { - // reset state - engine?.disconnect_calledCount = 0 - webSocketClient.connect() - - // Call `disconnect` with the given source - webSocketClient.disconnect(source: source) {} - - // Assert connection state is changed to disconnecting respecting the source - XCTAssertEqual(webSocketClient.connectionState, .disconnecting(source: source)) - - // Assert disconnect is called - AssertAsync.willBeEqual(engine!.disconnect_calledCount, 1) - - // Simulate engine disconnection - engine!.simulateDisconnect() - - // Assert state is `disconnected` with the correct source - AssertAsync.willBeEqual(webSocketClient.connectionState, .disconnected(source: source)) - } - } - - func test_disconnect_whenInitialized_shouldDisconnect() { - // When in initialized state - XCTAssertEqual(webSocketClient.connectionState, .initialized) - - // Call disconnect when not connected - webSocketClient.disconnect {} - - // Assert connection state is updated - XCTAssertEqual(webSocketClient.connectionState, .disconnected(source: .userInitiated)) - } - - func test_connectionState_afterDecodingError() { - // Simulate connection - test_connectionFlow() - - decoder.decodedEvent = .failure( - DecodingError.keyNotFound( - EventPayload.CodingKeys.eventType, - .init(codingPath: [], debugDescription: "") - ) - ) - engine!.simulateMessageReceived() - - AssertAsync.staysEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) - } - - // MARK: - Ping Controller - - func test_webSocketPingController_connectionStateDidChange_calledWhenConnectionChanges() { - test_connectionFlow() - AssertAsync.willBeEqual( - pingController.connectionStateDidChange_connectionStates, - [.connecting, .authenticating, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))] - ) - } - - func test_webSocketPingController_ping_callsEngineWithPing() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - // Reset the counter - engine!.sendPing_calledCount = 0 - - pingController.delegate?.sendPing() - AssertAsync.willBeEqual(engine!.sendPing_calledCount, 1) - } - - func test_pongReceived_callsPingController_pongReceived() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - assert(pingController.pongReceivedCount == 1) - - // Simulate a health check (pong) event is received - decoder.decodedEvent = .success(HealthCheckEvent(connectionId: connectionId)) - engine!.simulateMessageReceived() - - AssertAsync.willBeEqual(pingController.pongReceivedCount, 2) - } - - func test_webSocketPingController_disconnectOnNoPongReceived_disconnectsEngine() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - - assert(engine!.disconnect_calledCount == 0) - - pingController.delegate?.disconnectOnNoPongReceived() - - AssertAsync { - Assert.willBeEqual(self.webSocketClient.connectionState, .disconnecting(source: .noPongReceived)) - Assert.willBeEqual(self.engine!.disconnect_calledCount, 1) - } - } - - // MARK: - Setting a new connect endpoint - - func test_changingConnectEndpointAndReconnecting() { - // Simulate connection - test_connectionFlow() - requestEncoder.encodeRequest_endpoints = [] - - // Save the original engine reference - let oldEngine = engine - - // Simulate connect endpoint is updated (i.e. new user is logged in) - let newEndpoint = Endpoint( - path: .guest, - method: .get, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - webSocketClient.connectEndpoint = newEndpoint - - // Simulate request encoder response - let newRequest = URLRequest(url: .unique()) - requestEncoder.encodeRequest = .success(newRequest) - - // Disconnect - assert(engine!.disconnect_calledCount == 0) - webSocketClient.disconnect {} - AssertAsync.willBeEqual(engine!.disconnect_calledCount, 1) - - // Reconnect again - webSocketClient.connect() - XCTAssertEqual(requestEncoder.encodeRequest_endpoints.first, AnyEndpoint(newEndpoint)) - - // Check the engine got recreated - XCTAssert(engine !== oldEngine) - - AssertAsync { - Assert.willBeEqual(self.engine!.request, newRequest) - Assert.willBeEqual(self.engine!.connect_calledCount, 1) - } - } - - // MARK: - Event handling tests - - func test_connectionStatusUpdated_eventsArePublished_whenWSConnectionStateChanges() { - // Start logging events - let eventLogger = EventLogger(eventNotificationCenter) - - // Simulate connection state changes - let connectionStates: [WebSocketConnectionState] = [ - .connecting, - .connecting, // duplicate state should be ignored - .authenticating, - .authenticating, // duplicate state should be ignored - .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), - .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), // duplicate state should be ignored - .disconnecting(source: .userInitiated), - .disconnecting(source: .userInitiated), // duplicate state should be ignored - .disconnected(source: .userInitiated), - .disconnected(source: .userInitiated) // duplicate state should be ignored - ] - - connectionStates.forEach { webSocketClient.simulateConnectionStatus($0) } - - let expectedEvents = [ - WebSocketConnectionState.connecting, // states 0...3 - .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId)), // states 4...5 - .disconnecting(source: .userInitiated), // states 6...7 - .disconnected(source: .userInitiated) // states 8...9 - ].map { - ConnectionStatusUpdated(webSocketConnectionState: $0).asEquatable - } - - AssertAsync.willBeEqual(eventLogger.equatableEvents, expectedEvents) - } - - func test_currentUserDTOExists_whenStateIsConnected() throws { - try XCTSkipIf( - ProcessInfo().operatingSystemVersion.majorVersion < 15, - "https://github.com/GetStream/ios-issues-tracking/issues/515" - ) - - // Add `EventDataProcessorMiddleware` which is responsible for saving CurrentUser - let eventDataProcessorMiddleware = EventDataProcessorMiddleware() - webSocketClient.eventNotificationCenter.add(middleware: eventDataProcessorMiddleware) - - // Simulate connection - - // Simulate response from the encoder - let request = URLRequest(url: .unique()) - requestEncoder.encodeRequest = .success(request) - - // Assert that `CurrentUserDTO` does not exist - var currentUser: CurrentUserDTO? { - database.viewContext.currentUser - } - - XCTAssertNil(currentUser) - - // Call `connect`, it should change connection state and call `connect` on the engine - webSocketClient.connectEndpoint = endpoint - webSocketClient.connect() - - AssertAsync { - Assert.willBeEqual(self.engine!.connect_calledCount, 1) - } - - // Simulate the engine is connected and check the connection state is updated - engine!.simulateConnectionSuccess() - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate a health check event is received and the connection state is updated - let payloadCurrentUser = dummyCurrentUser - let eventPayload = EventPayload( - eventType: .healthCheck, - connectionId: connectionId, - cid: nil, - currentUser: payloadCurrentUser, - channel: nil - ) - decoder.decodedEvent = .success(try HealthCheckEvent(from: eventPayload)) - engine!.simulateMessageReceived() - - // We should see `CurrentUserDTO` being saved before we get connectionId - AssertAsync.willBeEqual(currentUser?.user.id, payloadCurrentUser.id) - AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) - } - - func test_whenHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() throws { - // Simulate response from the encoder - let request = URLRequest(url: .unique()) - requestEncoder.encodeRequest = .success(request) - - // Assign connect endpoint - webSocketClient.connectEndpoint = endpoint - - // Connect the web-socket client - webSocketClient.connect() - - // Wait for engine to be called - AssertAsync.willBeEqual(engine!.connect_calledCount, 1) - - // Simulate engine established connection - engine!.simulateConnectionSuccess() - - // Wait for the connection state to be propagated to web-socket client - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate received health check event - let healthCheckEvent = HealthCheckEvent(connectionId: .unique) - decoder.decodedEvent = .success(healthCheckEvent) - engine!.simulateMessageReceived() - - // Assert healtch check event does not get batched - let batchedEvents = eventsBatcher.mock_append.calls.map(\.asEquatable) - XCTAssertFalse(batchedEvents.contains(healthCheckEvent.asEquatable)) - - // Assert health check event was processed - let (_, postNotification, _) = try XCTUnwrap( - eventNotificationCenter.mock_process.calls.first(where: { events, _, _ in - events.first is HealthCheckEvent - }) - ) - - // Assert health check events was not posted - XCTAssertFalse(postNotification) - } - - func test_whenNonHealthCheckEventComes_getsBatchedAndPostedAfterProcessing() throws { - // Simulate connection - test_connectionFlow() - - // Clear state - eventsBatcher.mock_append.calls.removeAll() - eventNotificationCenter.mock_process.calls.removeAll() - - // Simulate incoming event - let incomingEvent = UserPresenceChangedEvent(user: .unique, createdAt: .unique) - decoder.decodedEvent = .success(incomingEvent) - engine!.simulateMessageReceived() - - // Assert event gets batched - XCTAssertEqual( - eventsBatcher.mock_append.calls.map(\.asEquatable), - [incomingEvent.asEquatable] - ) - - // Assert incoming event get processed and posted - let (events, postNotifications, completion) = try XCTUnwrap(eventNotificationCenter.mock_process.calls.first) - XCTAssertEqual(events.map(\.asEquatable), [incomingEvent.asEquatable]) - XCTAssertTrue(postNotifications) - XCTAssertNotNil(completion) - } - - func test_whenDisconnectHappens_immidiateBatchedEventsProcessingIsTriggered() { - // Simulate connection - test_connectionFlow() - - // Assert `processImmediately` was not triggered - XCTAssertFalse(eventsBatcher.mock_processImmediately.called) - - // Simulate disconnection - let expectation = expectation(description: "disconnect completion") - webSocketClient.disconnect { - expectation.fulfill() - } - - // Assert `processImmediately` is triggered - AssertAsync.willBeTrue(eventsBatcher.mock_processImmediately.called) - - // Simulate batch processing completion - eventsBatcher.mock_processImmediately.calls.last?() - - // Assert completion called - wait(for: [expectation], timeout: defaultTimeout) - } -} diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift deleted file mode 100644 index 81bda359474..00000000000 --- a/Tests/StreamChatTests/WebSocketClient/WebSocketPingController_Tests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class WebSocketPingController_Tests: XCTestCase { - var time: VirtualTime! - var pingController: WebSocketPingController! - private var delegate: WebSocketPingController_Delegate! - - override func setUp() { - super.setUp() - time = VirtualTime() - VirtualTimeTimer.time = time - pingController = .init(timerType: VirtualTimeTimer.self, timerQueue: .main) - - delegate = WebSocketPingController_Delegate() - pingController.delegate = delegate - } - - override func tearDown() { - VirtualTimeTimer.invalidate() - time = nil - pingController = nil - delegate = nil - super.tearDown() - } - - func test_sendPing_called_whenTheConnectionIsConnected() throws { - assert(delegate.sendPing_calledCount == 0) - - // Check `sendPing` is not called when the connection is not connected - time.run(numberOfSeconds: WebSocketPingController.pingTimeInterval + 1) - XCTAssertEqual(delegate.sendPing_calledCount, 0) - - // Set the connection state as connected - pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) - - // Simulate time passed 3x pingTimeInterval (+1 for margin errors) - time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) - XCTAssertEqual(delegate.sendPing_calledCount, 3) - - let oldPingCount = delegate.sendPing_calledCount - - // Set the connection state to not connected and check `sendPing` is no longer called - pingController.connectionStateDidChange(.authenticating) - time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) - XCTAssertEqual(delegate.sendPing_calledCount, oldPingCount) - } - - func test_disconnectOnNoPongReceived_called_whenNoPongReceived() throws { - // Set the connection state as connected - pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) - - assert(delegate.sendPing_calledCount == 0) - - // Simulate time passing and wait for `sendPing` call - while delegate.sendPing_calledCount != 1 { - time.run(numberOfSeconds: 1) - } - - // Simulate pong received - pingController.pongReceived() - - // Simulate time passed pongTimeoutTimeInterval + 1 and check disconnectOnNoPongReceived wasn't called - assert(delegate.disconnectOnNoPongReceived_calledCount == 0) - time.run(numberOfSeconds: WebSocketPingController.pongTimeoutTimeInterval + 1) - XCTAssertEqual(delegate.disconnectOnNoPongReceived_calledCount, 0) - - assert(delegate.sendPing_calledCount == 1) - - // Simulate time passing and wait for another `sendPing` call - while delegate.sendPing_calledCount != 2 { - time.run(numberOfSeconds: 1) - } - - // Simulate time passed pongTimeoutTimeInterval + 1 without receiving a pong - assert(delegate.disconnectOnNoPongReceived_calledCount == 0) - time.run(numberOfSeconds: WebSocketPingController.pongTimeoutTimeInterval + 1) - - // `disconnectOnNoPongReceived` should be called - XCTAssertEqual(delegate.disconnectOnNoPongReceived_calledCount, 1) - } -} diff --git a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift deleted file mode 100644 index dc0515b40df..00000000000 --- a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift +++ /dev/null @@ -1,650 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import CoreData -@testable import StreamChat -@testable import StreamChatTestTools -import XCTest - -final class ConnectionRecoveryHandler_Tests: XCTestCase { - var handler: DefaultConnectionRecoveryHandler! - var mockChatClient: ChatClient_Mock! - var mockInternetConnection: InternetConnection_Mock! - var mockBackgroundTaskScheduler: BackgroundTaskScheduler_Mock! - var mockRetryStrategy: RetryStrategy_Spy! - var mockTime: VirtualTime { VirtualTimeTimer.time! } - - override func setUp() { - super.setUp() - - VirtualTimeTimer.time = .init() - - mockChatClient = ChatClient_Mock(config: .init(apiKeyString: .unique)) - mockBackgroundTaskScheduler = BackgroundTaskScheduler_Mock() - mockRetryStrategy = RetryStrategy_Spy() - mockRetryStrategy.mock_nextRetryDelay.returns(5) - mockInternetConnection = .init(notificationCenter: mockChatClient.eventNotificationCenter) - } - - override func tearDown() { - AssertAsync.canBeReleased(&handler) - AssertAsync.canBeReleased(&mockChatClient) - AssertAsync.canBeReleased(&mockInternetConnection) - AssertAsync.canBeReleased(&mockRetryStrategy) - AssertAsync.canBeReleased(&mockBackgroundTaskScheduler) - - handler = nil - mockChatClient = nil - mockInternetConnection = nil - mockRetryStrategy = nil - mockBackgroundTaskScheduler = nil - VirtualTimeTimer.invalidate() - - super.tearDown() - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. internet -> OFF (no disconnect, no bg task, no timer) - /// 2. internet -> ON (no reconnect) - func test_socketIsInitialized_internetOffOn() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Internet -> OFF - mockInternetConnection.monitorMock.status = .unavailable - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert no background task started - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // Internet -> ON - mockInternetConnection.monitorMock.status = .available(.great) - - // Assert no reconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. app -> background (no disconnect, no bg task, no timer) - /// 2. app -> foregorund (no reconnect) - func test_socketIsInitialized_appBackgroundForeground() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert no background task started - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // App -> foreground - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Assert no reconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. ws -> connected - /// 2. ws -> disconnected by user - /// 3. internet -> OFF (no disconnect, no bg task, no timer) - /// 4. internet -> ON (no reconnect) - func test_socketIsDisconnectedByUser_internetOffOn() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Disconnect (user initiated) - disconnectWebSocket(source: .userInitiated) - - // Internet -> OFF - mockInternetConnection.monitorMock.status = .unavailable - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert no background task started - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // Internet -> ON - mockInternetConnection.monitorMock.status = .available(.great) - - // Assert no reconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. ws -> connected - /// 2. ws -> disconnected by user - /// 3. app -> background (no disconnect, no bg task, no timer) - /// 4. app -> foregorund (no reconnect) - func test_socketIsDisconnectedByUser_appBackgroundForeground() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Disconnect (user initiated) - disconnectWebSocket(source: .userInitiated) - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert no background task started - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // App -> foregorund - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Assert no reconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. ws -> connected - /// 2. internet -> OFF (no bg task, no timer) - /// 3. internet -> ON (reconnect) - func test_socketIsConnected_appBackgroundForeground() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Internet -> OFF - mockInternetConnection.monitorMock.status = .unavailable - - // Assert no background task - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // Disconnect (system initiated) - disconnectWebSocket(source: .systemInitiated) - - // Internet -> ON - mockInternetConnection.monitorMock.status = .available(.great) - - // Assert reconnection happens - XCTAssertTrue(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == true - /// - /// 1. ws -> connected - /// 2. app -> background (disconnect, background task is started, no timer) - /// 3. app -> foregorund (reconnect, background task is ended) - func test_socketIsConnected_appBackgroundTaskRunningAppForeground() { - // Create handler active in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: true) - - // Connect - connectWebSocket() - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert background task is started - XCTAssertTrue(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // App -> foregorund - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Assert background task is ended - XCTAssertTrue(mockBackgroundTaskScheduler.endBackgroundTask_called) - - // Assert the reconnection does not happen since client is still connected - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == true - /// - /// 1. ws -> connected - /// 2. app -> background (no disconnect, background task is started, no timer) - /// 3. bg task -> killed (disconnect) - /// 3. app -> foregorund (reconnect) - func test_socketIsConnected_appBackgroundTaskKilledAppForeground() async throws { - // Create handler active in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: true) - - // Connect - connectWebSocket() - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert disconnect is not called because it should stay connected in background - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert background task is started so client stays connected in background - XCTAssertTrue(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - // Backgroud task killed - try await Task.mainActor { - self.mockBackgroundTaskScheduler.beginBackgroundTask_expirationHandler?() - }.value - - // Assert disconnection is initiated by the system - XCTAssertEqual(mockChatClient.mockWebSocketClient.disconnect_source, .systemInitiated) - - // Disconnect (system initiated) - disconnectWebSocket(source: .systemInitiated) - - // App -> foregorund - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Assert reconnection happens - XCTAssertTrue(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == false - /// - /// 1. ws -> connected - /// 2. app -> background (disconnect, no bg task, no timer) - /// 3. app -> foregorund (reconnect) - func test_socketIsConnected_internetOffOn() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert disconnect is initiated by the sytem - XCTAssertEqual(mockChatClient.mockWebSocketClient.disconnect_source, .systemInitiated) - // Assert no background task - XCTAssertFalse(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - // Disconnect (system initiated) - disconnectWebSocket(source: .systemInitiated) - - // App -> foregorund - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Assert reconnection happens - XCTAssertTrue(mockChatClient.mockWebSocketClient.connect_called) - } - - /// keepConnectionAliveInBackground == true - /// - /// 1. ws -> connected - /// 2. app -> background (no disconnect, background task is started, no timer) - /// 3. internet -> OFF - /// 4. internet -> ON (no reconnect in background) - /// 5. internet -> OFF (no disconnect) - /// 6. app -> foregorund (reconnect) - /// 7. internet -> ON (reconnect) - func test_socketIsConnected_appBackgroundInternetOffOnOffAppForegroundInternetOn() { - // Create handler active in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: true) - - // Connect - connectWebSocket() - - // App -> background - mockBackgroundTaskScheduler.simulateAppGoingToBackground() - - // Assert no disconnect - XCTAssertFalse(mockChatClient.mockWebSocketClient.disconnect_called) - // Assert background task is started - XCTAssertTrue(mockBackgroundTaskScheduler.beginBackgroundTask_called) - // Assert no reconnect timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - - // Internet -> OFF - mockInternetConnection.monitorMock.status = .unavailable - - // Disconnect (system initiated) - disconnectWebSocket(source: .systemInitiated) - - // Reset calls counts - mockChatClient.mockWebSocketClient.disconnect_calledCounter = 0 - mockChatClient.mockWebSocketClient.connect_calledCounter = 0 - - // Internet -> ON - mockInternetConnection.monitorMock.status = .available(.great) - - // Assert no reconnect in background - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - - // Internet -> OFF - mockInternetConnection.monitorMock.status = .unavailable - - // App -> foregorund - mockBackgroundTaskScheduler.simulateAppGoingToForeground() - - // Internet -> ON - mockInternetConnection.monitorMock.status = .available(.great) - - // Assert reconnection happens - XCTAssertTrue(mockChatClient.mockWebSocketClient.connect_called) - } - - /// 1. ws -> connected - /// 2. ws -> disconnected by server with no error (timer starts) - /// 3. retry delay -> passed (reconnect) - func test_socketIsConnected_serverInitiatesDisconnectWithoutError() throws { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Mock retry delay - let retryDelay: TimeInterval = 5 - mockRetryStrategy.mock_nextRetryDelay.returns(retryDelay) - - // Connect - connectWebSocket() - - // Disconnect (server initiated) - disconnectWebSocket(source: .serverInitiated(error: nil)) - - // Assert timer is scheduled with correct delay - let timer = try XCTUnwrap(mockTime.scheduledTimers.first { $0.scheduledFireTime == retryDelay }) - // Assert timer is non repeated - XCTAssertFalse(timer.isRepeated) - // Assert timer is active - XCTAssertTrue(timer.isActive) - - // Wait for reconnection delay to pass - mockTime.run(numberOfSeconds: 10) - - // Assert reconnection happens - XCTAssertTrue(mockChatClient.mockWebSocketClient.connect_called) - } - - /// 1. ws -> connected - /// 2. ws -> disconnected by server with client error (no timer) - func test_socketIsConnected_serverInitiatesDisconnectWithClientError() throws { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Disconnect (server initiated) - let clientError = ClientError( - with: ErrorPayload( - code: .unique, - message: .unique, - statusCode: ClosedRange.clientErrorCodes.lowerBound - ) - ) - disconnectWebSocket(source: .serverInitiated(error: clientError)) - - // Assert reconnection timer is not scheduled - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - } - - /// 1. ws -> connected - /// 2. ws -> disconnected by server with token error (no timer) - func test_socketIsConnected_serverInitiatesDisconnectionWithTokenError() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Disconnect (server initiated) - let tokenError = ClientError( - with: ErrorPayload( - code: ClosedRange.tokenInvalidErrorCodes.lowerBound, - message: .unique, - statusCode: .unique - ) - ) - disconnectWebSocket(source: .serverInitiated(error: tokenError)) - - // Assert no reconnection timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - } - - /// 1. ws -> connected - /// 2. ws -> disconnected by server with stop error (no timer) - func test_socketIsConnected_serverInitiatesDisconnectionWithStopError() { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Connect - connectWebSocket() - - // Disconnect (server initiated) - let stopError = ClientError( - with: WebSocketEngineError( - reason: .unique, - code: WebSocketEngineError.stopErrorCode, - engineError: nil - ) - ) - disconnectWebSocket(source: .serverInitiated(error: stopError)) - - // Assert no reconnection timer - XCTAssertTrue(mockTime.scheduledTimers.isEmpty) - } - - /// 1. ws -> connected - /// 2. ws -> disconnected by server without error (time starts) - /// 3. ws -> connecting (timer is cancelled) - func test_socketIsWaitingForReconnect_connectionIsInitatedManually() throws { - // Create handler passive in background - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Mock retry delay - let retryDelay: TimeInterval = 5 - mockRetryStrategy.mock_nextRetryDelay.returns(retryDelay) - - // Connect - connectWebSocket() - - // Disconnect (server initiated) - disconnectWebSocket(source: .serverInitiated(error: nil)) - - // Assert timer is scheduled with correct delay - let timer = try XCTUnwrap(mockTime.scheduledTimers.first { $0.scheduledFireTime == retryDelay }) - // Assert timer is non repeated - XCTAssertFalse(timer.isRepeated) - // Assert timer is active - XCTAssertTrue(timer.isActive) - - // Connect - mockChatClient.mockWebSocketClient.simulateConnectionStatus(.connecting) - - // Assert timer is cancelled - XCTAssertFalse(timer.isActive) - } - - // MARK: - Websocket connection - - func test_webSocketStateUpdate_connecting() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_connecting_whenTimeout_whenNotRunning_shouldStartTimeout() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_connecting_whenTimeout_whenRunning_shouldNotStartTimeout() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_connected() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: "124"))) - - XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) - XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) - } - - func test_webSocketStateUpdate_connected_whenTimeout_shouldStopTimeout() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: "124"))) - - XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) - XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) - } - - func test_webSocketStateUpdate_disconnected_userInitiated() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // We need to set the state on the client as well - let status = WebSocketConnectionState.disconnected(source: .userInitiated) - mockChatClient.webSocketClient?.simulateConnectionStatus(status) - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: status) - - // getDelayAfterTheFailure() calls nextRetryDelay() & incrementConsecutiveFailures() internally - XCTAssertNotCall(RetryStrategy_Spy.Signature.nextRetryDelay, on: mockRetryStrategy) - XCTAssertNotCall("incrementConsecutiveFailures()", on: mockRetryStrategy) - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_disconnected_systemInitiated() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // We need to set the state on the client as well - let status = WebSocketConnectionState.disconnected(source: .systemInitiated) - mockRetryStrategy.mock_nextRetryDelay.returns(5) - mockChatClient.webSocketClient?.simulateConnectionStatus(status) - mockRetryStrategy.clear() - mockChatClient.mockSyncRepository.clear() - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: status) - - // getDelayAfterTheFailure() calls nextRetryDelay() & incrementConsecutiveFailures() internally - XCTAssertCall(RetryStrategy_Spy.Signature.nextRetryDelay, on: mockRetryStrategy, times: 1) - XCTAssertCall("incrementConsecutiveFailures()", on: mockRetryStrategy, times: 1) - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_initialized() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .initialized) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_waitingForConnectionId() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Simulate connection update - handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .authenticating) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } - - func test_webSocketStateUpdate_disconnecting() { - handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false) - - // Simulate connection update - handler.webSocketClient( - mockChatClient.mockWebSocketClient, - didUpdateConnectionState: .disconnecting(source: .systemInitiated) - ) - - XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) - } -} - -// MARK: - Private - -private extension ConnectionRecoveryHandler_Tests { - func makeConnectionRecoveryHandler( - keepConnectionAliveInBackground: Bool, - withReconnectionTimeout: Bool = false - ) -> DefaultConnectionRecoveryHandler { - let handler = DefaultConnectionRecoveryHandler( - webSocketClient: mockChatClient.mockWebSocketClient, - eventNotificationCenter: mockChatClient.eventNotificationCenter, - syncRepository: mockChatClient.mockSyncRepository, - backgroundTaskScheduler: mockBackgroundTaskScheduler, - internetConnection: mockInternetConnection, - reconnectionStrategy: mockRetryStrategy, - reconnectionTimerType: VirtualTimeTimer.self, - keepConnectionAliveInBackground: keepConnectionAliveInBackground - ) - handler.start() - - // Make a handler a delegate to simlulate real life chain when - // connection changes are propagated back to the handler. - mockChatClient.webSocketClient?.connectionStateDelegate = handler - - return handler - } - - func connectWebSocket() { - let ws = mockChatClient.mockWebSocketClient - - ws.simulateConnectionStatus(.connecting) - ws.simulateConnectionStatus(.authenticating) - ws.simulateConnectionStatus(.connected(healthCheckInfo: HealthCheckInfo(connectionId: .unique))) - } - - func disconnectWebSocket(source: WebSocketConnectionState.DisconnectionSource) { - let ws = mockChatClient.mockWebSocketClient - - ws.simulateConnectionStatus(.disconnecting(source: source)) - ws.simulateConnectionStatus(.disconnected(source: source)) - } -} diff --git a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift index fb2dd68ad51..82c3f9ed861 100644 --- a/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift +++ b/Tests/StreamChatTests/Workers/EventNotificationCenter_Tests.swift @@ -30,7 +30,7 @@ final class EventNotificationCenter_Tests: XCTestCase { ] // Create notification center with middlewares - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) middlewares.forEach(center.add) // Assert middlewares are assigned correctly @@ -50,7 +50,7 @@ final class EventNotificationCenter_Tests: XCTestCase { ] // Create notification center without any middlewares - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Add middlewares via `add` method middlewares.forEach(center.add) @@ -67,7 +67,7 @@ final class EventNotificationCenter_Tests: XCTestCase { let consumingMiddleware = EventMiddleware_Mock { _, _ in nil } // Create a notification center with blocking middleware - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) center.add(middleware: consumingMiddleware) // Create event logger to check published events @@ -82,7 +82,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_eventIsPublishedAsItIs_ifThereAreNoMiddlewares() { // Create a notification center without any middlewares - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -97,7 +97,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_eventsAreProcessed_fromWithinTheWriteClosure() { // Create a notification center without any middlewares - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -126,7 +126,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_process_whenShouldPostEventsIsTrue_eventsArePosted() { // Create a notification center with just a forwarding middleware - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -149,7 +149,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_process_whenShouldPostEventsIsFalse_eventsAreNotPosted() { // Create a notification center with just a forwarding middleware - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -172,7 +172,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_process_postsEventsOnPostingQueue() { // Create notification center - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Assign mock events posting queue let mockQueueUUID = UUID() @@ -216,7 +216,7 @@ final class EventNotificationCenter_Tests: XCTestCase { let outputEvent = TestEvent() // Create a notification center - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -269,7 +269,7 @@ final class EventNotificationCenter_Tests: XCTestCase { } // Create a notification center - let center = EventNotificationCenter(database: database) + let center = PersistentEventNotificationCenter(database: database) measure { center.process(events) @@ -280,7 +280,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_registerManualEventHandling_callsManualEventHandler() { let mockHandler = ManualEventHandler_Mock() - let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let center = PersistentEventNotificationCenter(database: database, manualEventHandler: mockHandler) let cid: ChannelId = .unique center.registerManualEventHandling(for: cid) @@ -291,7 +291,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_unregisterManualEventHandling_callsManualEventHandler() { let mockHandler = ManualEventHandler_Mock() - let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let center = PersistentEventNotificationCenter(database: database, manualEventHandler: mockHandler) let cid: ChannelId = .unique center.unregisterManualEventHandling(for: cid) @@ -302,7 +302,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_process_whenManualEventHandlerReturnsEvent_eventIsAddedToEventsToPost() { let mockHandler = ManualEventHandler_Mock() - let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let center = PersistentEventNotificationCenter(database: database, manualEventHandler: mockHandler) // Create event logger to check published events let eventLogger = EventLogger(center) @@ -326,7 +326,7 @@ final class EventNotificationCenter_Tests: XCTestCase { func test_process_whenManualEventHandlerReturnsNil_eventIsProcessedByMiddlewares() { let mockHandler = ManualEventHandler_Mock() - let center = EventNotificationCenter(database: database, manualEventHandler: mockHandler) + let center = PersistentEventNotificationCenter(database: database, manualEventHandler: mockHandler) // Create event logger to check published events let eventLogger = EventLogger(center) From 43fd18e9e3ce371268a272df689a4daefbd6abc2 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 31 Oct 2025 09:46:03 +0200 Subject: [PATCH 09/17] Fix EventDecoder test --- .../WebSocketClient/Events/EventDecoder.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift index b8f95bdacd4..dddeb6f46f7 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift @@ -18,8 +18,14 @@ struct EventDecoder { } catch let error as ClientError.IgnoredEventType { throw error } catch { - let errorPayload = try decoder.decode(ErrorPayloadContainer.self, from: data) - return errorPayload.toEvent() + // Web-socket errors are passed on as a custom event to the web-socket client + // which in turn triggers a disconnection if needed. When chat moves to + // OpenAPI, this can be removed from here. + if let errorPayload = try? decoder.decode(ErrorPayloadContainer.self, from: data) { + return errorPayload.toEvent() + } else { + throw error + } } } } From 7e3850712f6c8e03b27808e363b7446b970b35e9 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 31 Oct 2025 11:14:13 +0200 Subject: [PATCH 10/17] Sinatra should return 401 like Stream backend on token expiration --- fastlane/sinatra.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/sinatra.rb b/fastlane/sinatra.rb index 175109191d1..7c009afd65c 100644 --- a/fastlane/sinatra.rb +++ b/fastlane/sinatra.rb @@ -44,7 +44,7 @@ if time < jwt[:generation_error_timeout][params['udid']].to_i halt(500, 'Intentional error') elsif time < jwt[:expiration_timeout][params['udid']].to_i - 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIiLCJleHAiOjE2NjgwMTIzNTN9.UJ-LDHZFDP10sqpZU9bzPAChgersjDfqKjoi5Plg8qI' + halt(401, 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIiLCJleHAiOjE2NjgwMTIzNTN9.UJ-LDHZFDP10sqpZU9bzPAChgersjDfqKjoi5Plg8qI') else client = StreamChat::Client.new(params[:api_key], ENV.fetch('STREAM_DEMO_APP_SECRET')) expiration = time + 5 From 4bab5d5f8c183d49b9e8c985276702d5dd20f2b1 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 31 Oct 2025 11:18:44 +0200 Subject: [PATCH 11/17] Revert "Sinatra should return 401 like Stream backend on token expiration" This reverts commit 7e3850712f6c8e03b27808e363b7446b970b35e9. --- fastlane/sinatra.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/sinatra.rb b/fastlane/sinatra.rb index 7c009afd65c..175109191d1 100644 --- a/fastlane/sinatra.rb +++ b/fastlane/sinatra.rb @@ -44,7 +44,7 @@ if time < jwt[:generation_error_timeout][params['udid']].to_i halt(500, 'Intentional error') elsif time < jwt[:expiration_timeout][params['udid']].to_i - halt(401, 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIiLCJleHAiOjE2NjgwMTIzNTN9.UJ-LDHZFDP10sqpZU9bzPAChgersjDfqKjoi5Plg8qI') + 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIiLCJleHAiOjE2NjgwMTIzNTN9.UJ-LDHZFDP10sqpZU9bzPAChgersjDfqKjoi5Plg8qI' else client = StreamChat::Client.new(params[:api_key], ENV.fetch('STREAM_DEMO_APP_SECRET')) expiration = time + 5 From 68fa52272257b56be7dc41143327a8ac6002b171 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 31 Oct 2025 13:51:24 +0200 Subject: [PATCH 12/17] Fix reconnection after token expiry tests --- .../StreamChat/APIClient/RequestDecoder.swift | 2 +- .../Repositories/ConnectionRepository.swift | 2 +- .../Events/ConnectionEvents.swift | 17 ----------------- .../WebSocketClient/Events/EventDecoder.swift | 9 --------- 4 files changed, 2 insertions(+), 28 deletions(-) diff --git a/Sources/StreamChat/APIClient/RequestDecoder.swift b/Sources/StreamChat/APIClient/RequestDecoder.swift index 07f9dda9b6c..e0ca0eb2868 100644 --- a/Sources/StreamChat/APIClient/RequestDecoder.swift +++ b/Sources/StreamChat/APIClient/RequestDecoder.swift @@ -58,7 +58,7 @@ struct DefaultRequestDecoder: RequestDecoder { throw ClientError.Unknown("Unknown error. Server response: \(httpResponse).") } - if serverError.isExpiredTokenError { + if serverError.isTokenExpiredError { log.info("Request failed because of an expired token.", subsystems: .httpRequests) throw ClientError.ExpiredToken() } diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index db920d0bba2..bd8de2cddca 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -164,7 +164,7 @@ class ConnectionRepository: @unchecked Sendable { syncRepository.syncLocalState { log.info("Local state sync completed", subsystems: .offlineSupport) } - case let .disconnected(source) where source.serverError?.isExpiredTokenError == true: + case let .disconnected(source) where source.serverError?.isTokenExpiredError == true: onExpiredToken() shouldNotifyConnectionIdWaiters = false connectionId = nil diff --git a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift index fe2b3668fc0..65a1c8e2007 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift @@ -38,23 +38,6 @@ public final class HealthCheckEvent: ConnectionEvent, EventDTO, Sendable { } } -struct WebSocketErrorEvent: Event { - let payload: ErrorPayload - - func error() -> (any Error)? { - payload - } -} - -struct ErrorPayloadContainer: Decodable { - /// A server error was received. - let error: ErrorPayload - - func toEvent() -> Event { - WebSocketErrorEvent(payload: error) - } -} - /// Emitted when `Client` changes it's connection status. You can listen to this event and indicate the different connection /// states in the UI (banners like "Offline", "Reconnecting"", etc.). public struct ConnectionStatusUpdated: Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift index dddeb6f46f7..b81c53d15b0 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift @@ -17,15 +17,6 @@ struct EventDecoder { return try decoder.decode(UnknownUserEvent.self, from: data) } catch let error as ClientError.IgnoredEventType { throw error - } catch { - // Web-socket errors are passed on as a custom event to the web-socket client - // which in turn triggers a disconnection if needed. When chat moves to - // OpenAPI, this can be removed from here. - if let errorPayload = try? decoder.decode(ErrorPayloadContainer.self, from: data) { - return errorPayload.toEvent() - } else { - throw error - } } } } From a34ca9d81e1003ae1bd5baccd568ee3ebe8d96e8 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Fri, 31 Oct 2025 14:34:37 +0200 Subject: [PATCH 13/17] Cleanup annotation --- DemoApp/Screens/DemoReminderListVC.swift | 2 +- DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DemoApp/Screens/DemoReminderListVC.swift b/DemoApp/Screens/DemoReminderListVC.swift index f0649478633..0bca7c2f9e9 100644 --- a/DemoApp/Screens/DemoReminderListVC.swift +++ b/DemoApp/Screens/DemoReminderListVC.swift @@ -478,7 +478,7 @@ extension DemoReminderListVC: MessageReminderListControllerDelegate, EventsContr updateRemindersData() } - func eventsController(_ controller: EventsController, didReceiveEvent event: any StreamChat.Event) { + func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { if event is MessageReminderDueEvent { updateReminderListsWithNewNowDate() } diff --git a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift index e6281e13582..a71a3e4500b 100644 --- a/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift +++ b/DemoApp/Screens/Livestream/DemoLivestreamChatChannelVC.swift @@ -393,7 +393,7 @@ class DemoLivestreamChatChannelVC: _ViewController, messageListVC.scrollToBottomButton.content = .init(messages: skippedMessagesAmount, mentions: 0) } - func eventsController(_ controller: EventsController, didReceiveEvent event: any StreamChat.Event) { + func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { if event is NewMessagePendingEvent { if livestreamChannelController.isPaused { pauseBannerView.setState(.resuming) From 3ce5e3dd90005234f0f6650063772a5e414a521f Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 3 Nov 2025 10:59:50 +0200 Subject: [PATCH 14/17] Add connection error event handling and set batching period to 0.5 --- Sources/StreamChat/ChatClient+Environment.swift | 7 +++++-- .../Events/ConnectionEvents.swift | 16 ++++++++++++++++ .../WebSocketClient/Events/EventPayload.swift | 5 +++++ .../WebSocketClient/Events/EventType.swift | 2 ++ .../Mocks/StreamChat/ChatClient_Mock.swift | 6 +----- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 0b37aea036d..c5fd51d71d9 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -28,12 +28,15 @@ extension ChatClient { _ eventDecoder: AnyEventDecoder, _ notificationCenter: PersistentEventNotificationCenter ) -> WebSocketClient)? = { - WebSocketClient( + let wsEnvironment = WebSocketClient.Environment(eventBatchingPeriod: 0.5) + return WebSocketClient( sessionConfiguration: $0, eventDecoder: $1, eventNotificationCenter: $2, webSocketClientType: .coordinator, - connectRequest: nil + environment: wsEnvironment, + connectRequest: nil, + healthCheckBeforeConnected: true ) } diff --git a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift index 65a1c8e2007..bc5698e22f8 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift @@ -38,6 +38,22 @@ public final class HealthCheckEvent: ConnectionEvent, EventDTO, Sendable { } } +final class ConnectionErrorEvent: Event { + let errorPayload: ErrorPayload + + init(from eventResponse: EventPayload) throws { + guard let errorPayload = eventResponse.connectionError else { + throw ClientError.EventDecoding(missingValue: "error", for: Self.self) + } + + self.errorPayload = errorPayload + } + + func error() -> (any Error)? { + errorPayload + } +} + /// Emitted when `Client` changes it's connection status. You can listen to this event and indicate the different connection /// states in the UI (banners like "Offline", "Reconnecting"", etc.). public struct ConnectionStatusUpdated: Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 1dfa0847fd3..deedde75b7c 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -11,6 +11,7 @@ final class EventPayload: Decodable, Sendable { enum CodingKeys: String, CodingKey, CaseIterable { case eventType = "type" case connectionId = "connection_id" + case connectionError = "error" case cid case channelType = "channel_type" case channelId = "channel_id" @@ -47,6 +48,7 @@ final class EventPayload: Decodable, Sendable { let eventType: EventType let connectionId: String? + let connectionError: ErrorPayload? let cid: ChannelId? let currentUser: CurrentUserPayload? let user: UserPayload? @@ -87,6 +89,7 @@ final class EventPayload: Decodable, Sendable { init( eventType: EventType, connectionId: String? = nil, + connectionError: ErrorPayload? = nil, cid: ChannelId? = nil, currentUser: CurrentUserPayload? = nil, user: UserPayload? = nil, @@ -122,6 +125,7 @@ final class EventPayload: Decodable, Sendable { ) { self.eventType = eventType self.connectionId = connectionId + self.connectionError = connectionError self.cid = cid self.currentUser = currentUser self.user = user @@ -160,6 +164,7 @@ final class EventPayload: Decodable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) eventType = try container.decode(EventType.self, forKey: .eventType) connectionId = try container.decodeIfPresent(String.self, forKey: .connectionId) + connectionError = try container.decodeIfPresent(ErrorPayload.self, forKey: .connectionError) // In healthCheck event we can receive invalid id containing "*". // We don't need to throw error in that case and can treat it like missing cid. cid = try? container.decodeIfPresent(ChannelId.self, forKey: .cid) diff --git a/Sources/StreamChat/WebSocketClient/Events/EventType.swift b/Sources/StreamChat/WebSocketClient/Events/EventType.swift index 0f7bc2b20a0..d14cfb0feb9 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventType.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventType.swift @@ -19,6 +19,7 @@ public struct EventType: RawRepresentable, Codable, Hashable, ExpressibleByStrin public extension EventType { static let healthCheck: Self = "health.check" + static let connectionError: Self = "connection.error" // MARK: User Events @@ -184,6 +185,7 @@ extension EventType { func event(from response: EventPayload) throws -> Event { switch self { case .healthCheck: return try HealthCheckEvent(from: response) + case .connectionError: return try ConnectionErrorEvent(from: response) case .userPresenceChanged: return try UserPresenceChangedEventDTO(from: response) case .userUpdated: return try UserUpdatedEventDTO(from: response) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 8b7e05a92e1..722d7990f49 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -382,11 +382,7 @@ extension ChatClient.Environment { static var withZeroEventBatchingPeriod: Self { .init( webSocketClientBuilder: { - var webSocketEnvironment = WebSocketClient.Environment() - webSocketEnvironment.eventBatcherBuilder = { - Batcher(period: 0, handler: $0) - } - + let webSocketEnvironment = WebSocketClient.Environment(eventBatchingPeriod: 0) return WebSocketClient( sessionConfiguration: $0, eventDecoder: $1, From 9117dfd820fe4fc51eb2405f15ec3fa6f9a2b7bb Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 3 Nov 2025 16:34:28 +0200 Subject: [PATCH 15/17] Clean up ErrorPayload --- Sources/StreamChat/APIClient/RequestDecoder.swift | 4 ++-- .../Repositories/MessageRepository.swift | 2 +- .../Error+InternetNotAvailable.swift | 4 ++-- .../WebSocketClient/Events/ConnectionEvents.swift | 8 ++++---- .../WebSocketClient/Events/EventPayload.swift | 6 +++--- .../APIClient/RequestDecoder_Tests.swift | 6 +++--- .../StreamChatTests/Errors/ClientError_Tests.swift | 2 +- .../Repositories/ConnectionRepository_Tests.swift | 14 +++++++------- .../Repositories/MessageRepository_Tests.swift | 2 +- .../Error+InternetNotAvailable_Tests.swift | 6 +++--- 10 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Sources/StreamChat/APIClient/RequestDecoder.swift b/Sources/StreamChat/APIClient/RequestDecoder.swift index e0ca0eb2868..819245e70f1 100644 --- a/Sources/StreamChat/APIClient/RequestDecoder.swift +++ b/Sources/StreamChat/APIClient/RequestDecoder.swift @@ -46,9 +46,9 @@ struct DefaultRequestDecoder: RequestDecoder { log.debug("URL request response: \(httpResponse), data:\n\(data.debugPrettyPrintedJSON))", subsystems: .httpRequests) guard httpResponse.statusCode < 300 else { - let serverError: ErrorPayload + let serverError: APIError do { - serverError = try JSONDecoder.default.decode(ErrorPayload.self, from: data) + serverError = try JSONDecoder.default.decode(APIError.self, from: data) } catch { log .error( diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 042675141d8..5ebd13c2454 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -228,7 +228,7 @@ class MessageRepository: @unchecked Sendable { ) { log.error("Sending the message with id \(messageId) failed with error: \(error)") - if let clientError = error as? ClientError, let errorPayload = clientError.errorPayload { + if let clientError = error as? ClientError, let errorPayload = clientError.apiError { // If the message already exists on the server we do not want to mark it as failed, // since this will cause an unrecoverable state, where the user will keep resending // the message and it will always fail. Right now, the only way to check this error is diff --git a/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift b/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift index 759aa1947da..be0d51ae994 100644 --- a/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift +++ b/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift @@ -26,7 +26,7 @@ extension Error { } var isBackendErrorWith400StatusCode: Bool { - if let error = (self as? ClientError)?.underlyingError as? ErrorPayload, + if let error = (self as? ClientError)?.apiError, error.statusCode == 400 { return true } @@ -34,7 +34,7 @@ extension Error { } var isBackendNotFound404StatusCode: Bool { - if let error = (self as? ClientError)?.underlyingError as? ErrorPayload, + if let error = (self as? ClientError)?.apiError, error.statusCode == 404 { return true } diff --git a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift index bc5698e22f8..9c3c8a60733 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift @@ -39,18 +39,18 @@ public final class HealthCheckEvent: ConnectionEvent, EventDTO, Sendable { } final class ConnectionErrorEvent: Event { - let errorPayload: ErrorPayload + let apiError: APIError init(from eventResponse: EventPayload) throws { - guard let errorPayload = eventResponse.connectionError else { + guard let apiError = eventResponse.connectionError else { throw ClientError.EventDecoding(missingValue: "error", for: Self.self) } - self.errorPayload = errorPayload + self.apiError = apiError } func error() -> (any Error)? { - errorPayload + apiError } } diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index deedde75b7c..d8a07bcc2b5 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -48,7 +48,7 @@ final class EventPayload: Decodable, Sendable { let eventType: EventType let connectionId: String? - let connectionError: ErrorPayload? + let connectionError: APIError? let cid: ChannelId? let currentUser: CurrentUserPayload? let user: UserPayload? @@ -89,7 +89,7 @@ final class EventPayload: Decodable, Sendable { init( eventType: EventType, connectionId: String? = nil, - connectionError: ErrorPayload? = nil, + connectionError: APIError? = nil, cid: ChannelId? = nil, currentUser: CurrentUserPayload? = nil, user: UserPayload? = nil, @@ -164,7 +164,7 @@ final class EventPayload: Decodable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) eventType = try container.decode(EventType.self, forKey: .eventType) connectionId = try container.decodeIfPresent(String.self, forKey: .connectionId) - connectionError = try container.decodeIfPresent(ErrorPayload.self, forKey: .connectionError) + connectionError = try container.decodeIfPresent(APIError.self, forKey: .connectionError) // In healthCheck event we can receive invalid id containing "*". // We don't need to throw error in that case and can treat it like missing cid. cid = try? container.decodeIfPresent(ChannelId.self, forKey: .cid) diff --git a/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift b/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift index 65fc3387d68..b10eab92d25 100644 --- a/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift +++ b/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift @@ -55,7 +55,7 @@ final class RequestDecoder_Tests: XCTestCase { func test_decodingResponseWithServerError() throws { // Prepare test data to simulate error payload from the server - let errorPayload = ErrorPayload(code: 0, message: "Test", statusCode: 400) + let errorPayload = APIError(code: 0, message: "Test", statusCode: 400) let data = try JSONEncoder.stream.encode(errorPayload) let response = HTTPURLResponse(url: .unique(), statusCode: 400, httpVersion: nil, headerFields: nil) @@ -63,13 +63,13 @@ final class RequestDecoder_Tests: XCTestCase { XCTAssertThrowsError(try { let _: Data = try self.decoder.decodeRequestResponse(data: data, response: response, error: nil) }()) { (error) in - XCTAssert((error as? ClientError)?.underlyingError is ErrorPayload) + XCTAssertNotNil((error as? ClientError)?.apiError) } } func test_decodingResponseWithServerError_containingExpiredToken() throws { // Prepare test data to simulate the "token expired" server error - let errorPayload = ErrorPayload(code: 40, message: "Test", statusCode: 400) + let errorPayload = APIError(code: 40, message: "Test", statusCode: 400) let data = try JSONEncoder.stream.encode(errorPayload) let response = HTTPURLResponse(url: .unique(), statusCode: 400, httpVersion: nil, headerFields: nil) diff --git a/Tests/StreamChatTests/Errors/ClientError_Tests.swift b/Tests/StreamChatTests/Errors/ClientError_Tests.swift index df3494ed653..3954506f128 100644 --- a/Tests/StreamChatTests/Errors/ClientError_Tests.swift +++ b/Tests/StreamChatTests/Errors/ClientError_Tests.swift @@ -9,7 +9,7 @@ import XCTest final class ClientError_Tests: XCTestCase { func test_rateLimitError_isNotEphemeralError() { - let errorPayload = ErrorPayload( + let errorPayload = APIError( code: 9, message: .unique, statusCode: 429 diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index d298f08a8c5..7f6f74c53da 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -116,7 +116,7 @@ final class ConnectionRepository_Tests: XCTestCase { expectation.fulfill() } - let invalidTokenError = ClientError(with: ErrorPayload( + let invalidTokenError = ClientError(with: APIError( code: .random(in: ClosedRange.tokenInvalidErrorCodes), message: .unique, statusCode: .unique @@ -253,7 +253,7 @@ final class ConnectionRepository_Tests: XCTestCase { // MARK: Handle connection update func test_handleConnectionUpdate_setsCorrectConnectionStatus() { - let invalidTokenError = ClientError(with: ErrorPayload( + let invalidTokenError = ClientError(with: APIError( code: .random(in: ClosedRange.tokenInvalidErrorCodes), message: .unique, statusCode: .unique @@ -278,13 +278,13 @@ final class ConnectionRepository_Tests: XCTestCase { } func test_handleConnectionUpdate_shouldNotifyWaitersWhenNeeded() { - let invalidTokenError = ClientError(with: ErrorPayload( + let invalidTokenError = ClientError(with: APIError( code: StreamErrorCode.accessKeyInvalid, message: .unique, statusCode: .unique )) - let expiredTokenError = ClientError(with: ErrorPayload( + let expiredTokenError = ClientError(with: APIError( code: StreamErrorCode.expiredToken, message: .unique, statusCode: .unique @@ -366,7 +366,7 @@ final class ConnectionRepository_Tests: XCTestCase { func test_handleConnectionUpdate_whenExpiredToken_shouldExecuteExpiredTokenBlock() { let expectation = self.expectation(description: "Expired Token Block Not Executed") - let expiredTokenError = ClientError(with: ErrorPayload( + let expiredTokenError = ClientError(with: APIError( code: StreamErrorCode.expiredToken, message: .unique, statusCode: .unique @@ -382,7 +382,7 @@ final class ConnectionRepository_Tests: XCTestCase { func test_handleConnectionUpdate_whenInvalidToken_shouldNotExecuteExpiredTokenBlock() { let expectation = self.expectation(description: "Expired Token Block Not Executed") expectation.isInverted = true - let invalidTokenError = ClientError(with: ErrorPayload( + let invalidTokenError = ClientError(with: APIError( code: StreamErrorCode.invalidTokenSignature, message: .unique, statusCode: .unique @@ -397,7 +397,7 @@ final class ConnectionRepository_Tests: XCTestCase { func test_handleConnectionUpdate_whenInvalidToken_whenDisconnecting_shouldNOTExecuteRefreshTokenBlock() { // We only want to refresh the token when it is actually disconnected, not while it is disconnecting, otherwise we trigger refresh token twice. - let invalidTokenError = ClientError(with: ErrorPayload( + let invalidTokenError = ClientError(with: APIError( code: .random(in: ClosedRange.tokenInvalidErrorCodes), message: .unique, statusCode: .unique diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index 985ad7b3bd9..6f5e6805b66 100644 --- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift @@ -114,7 +114,7 @@ final class MessageRepositoryTests: XCTestCase { wait(for: [apiClient.request_expectation], timeout: defaultTimeout) - let error = ClientError(with: ErrorPayload(code: 4, message: "Message X already exists.", statusCode: 400)) + let error = ClientError(with: APIError(code: 4, message: "Message X already exists.", statusCode: 400)) (apiClient.request_completion as? (Result) -> Void)?(.failure(error)) wait(for: [expectation], timeout: defaultTimeout) diff --git a/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift b/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift index 76c1066f5b5..06499cee6f8 100644 --- a/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift +++ b/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift @@ -99,15 +99,15 @@ final class Error_Tests: XCTestCase { XCTAssertFalse(error.isBackendErrorWith400StatusCode) } - func test_isBackendErrorWith400StatusCode_errorIsClientErrorWithErrorPayload() { + func test_isBackendErrorWith400StatusCode_errorIsClientErrorWithAPIError() { // When error is a ClientError, it's unerdlying error is a backend error, // but it's status code is not 400 - let error = ClientError(with: ErrorPayload(code: 0, message: "", statusCode: 503)) + let error = ClientError(with: APIError(code: 0, message: "", statusCode: 503)) XCTAssertFalse(error.isBackendErrorWith400StatusCode) } func test_isBackendErrorWith400StatusCode() { - let error = ClientError(with: ErrorPayload(code: 0, message: "", statusCode: 400)) + let error = ClientError(with: APIError(code: 0, message: "", statusCode: 400)) XCTAssertTrue(error.isBackendErrorWith400StatusCode) } } From 3ba4f4248b3ab05a3fc3f82f278c56c994ce4e2a Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Tue, 4 Nov 2025 11:20:52 +0200 Subject: [PATCH 16/17] Clean up StreamCore linking --- StreamChat.xcodeproj/project.pbxproj | 116 +-------------------------- 1 file changed, 4 insertions(+), 112 deletions(-) diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index e8f1033775f..1457b923203 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -239,12 +239,6 @@ 4F072F032BC008D9006A66CA /* StateLayerDatabaseObserver_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F072F022BC008D9006A66CA /* StateLayerDatabaseObserver_Tests.swift */; }; 4F0757772E9FC0D500E5FD18 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0757762E9FC0CF00E5FD18 /* StreamCore+Extensions.swift */; }; 4F0757782E9FC0D500E5FD18 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0757762E9FC0CF00E5FD18 /* StreamCore+Extensions.swift */; }; - 4F110E462EA8E28A00273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E452EA8E28A00273036 /* StreamCore */; }; - 4F110E482EA8E2BE00273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E472EA8E2BE00273036 /* StreamCore */; }; - 4F110E4A2EA8E2E800273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E492EA8E2E800273036 /* StreamCore */; }; - 4F110E4C2EA8E2FC00273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E4B2EA8E2FC00273036 /* StreamCore */; }; - 4F110E4E2EA8E9C100273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E4D2EA8E9C100273036 /* StreamCore */; }; - 4F110E502EA8E9FF00273036 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4F110E4F2EA8E9FF00273036 /* StreamCore */; }; 4F12DC8C2B70DE82009E48CC /* DifferenceKit+Stream_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F12DC8A2B70DE4C009E48CC /* DifferenceKit+Stream_Tests.swift */; }; 4F12DC922B73801D009E48CC /* NukeImageLoader_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F12DC912B73801D009E48CC /* NukeImageLoader_Tests.swift */; }; 4F14F1242BBA9CEF00B1074E /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F14F1232BBA9CEF00B1074E /* Result+Extensions.swift */; }; @@ -439,7 +433,6 @@ 4FC7445D2D8D9A2600E314EB /* Graphics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743BC2D8D9A2600E314EB /* Graphics.swift */; }; 4FCB7DF52EB229BB00908631 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB7DF42EB229B400908631 /* StreamCore+Extensions.swift */; }; 4FCB7DF62EB229BB00908631 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB7DF42EB229B400908631 /* StreamCore+Extensions.swift */; }; - 4FCB80B12EB2455A00908631 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FCB80B02EB2455A00908631 /* StreamCore */; }; 4FCCACE42BC939EB009D23E1 /* MemberList_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCCACE32BC939EB009D23E1 /* MemberList_Tests.swift */; }; 4FD2BE502B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; 4FD2BE512B99F68300FFC6F2 /* ReadStateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE4F2B99F68300FFC6F2 /* ReadStateHandler.swift */; }; @@ -450,14 +443,6 @@ 4FD2BE5C2B9AF8C300FFC6F2 /* ChannelListState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE5B2B9AF8C300FFC6F2 /* ChannelListState+Observer.swift */; }; 4FD2BE5D2B9AF8C300FFC6F2 /* ChannelListState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD2BE5B2B9AF8C300FFC6F2 /* ChannelListState+Observer.swift */; }; 4FD94FC52BCD5EF00084FEDF /* ConnectedUser_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD94FC42BCD5EF00084FEDF /* ConnectedUser_Tests.swift */; }; - 4FE28A892EA9115A0035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A882EA9115A0035B93E /* StreamCore */; }; - 4FE28A8B2EA9FC6E0035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A8A2EA9FC6E0035B93E /* StreamCore */; }; - 4FE28A8D2EA9FC7F0035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A8C2EA9FC7F0035B93E /* StreamCore */; }; - 4FE28A8F2EA9FF2B0035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A8E2EA9FF2B0035B93E /* StreamCore */; }; - 4FE28A912EA9FF350035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A902EA9FF350035B93E /* StreamCore */; }; - 4FE28A932EA9FF3A0035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A922EA9FF3A0035B93E /* StreamCore */; }; - 4FE28A952EA9FF400035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A942EA9FF400035B93E /* StreamCore */; }; - 4FE28A972EA9FF460035B93E /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FE28A962EA9FF460035B93E /* StreamCore */; }; 4FE56B772D5B5BB000589F9A /* ChatMessageMarkdown_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE56B762D5B5BA300589F9A /* ChatMessageMarkdown_Tests.swift */; }; 4FE56B8D2D5DFE4600589F9A /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE56B8C2D5DFE3A00589F9A /* MarkdownParser.swift */; }; 4FE56B8E2D5DFE4600589F9A /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE56B8C2D5DFE3A00589F9A /* MarkdownParser.swift */; }; @@ -466,13 +451,13 @@ 4FE6E1AB2BAC79F400C80AF1 /* MemberListState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6E1A92BAC79F400C80AF1 /* MemberListState+Observer.swift */; }; 4FE6E1AD2BAC7A1B00C80AF1 /* UserListState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6E1AC2BAC7A1B00C80AF1 /* UserListState+Observer.swift */; }; 4FE6E1AE2BAC7A1B00C80AF1 /* UserListState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE6E1AC2BAC7A1B00C80AF1 /* UserListState+Observer.swift */; }; - 4FECE0902E9392A3007D14F0 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FECE08F2E9392A3007D14F0 /* StreamCore */; }; 4FF2A80D2B8E011000941A64 /* ChatState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF2A80C2B8E011000941A64 /* ChatState+Observer.swift */; }; 4FF2A80E2B8E011000941A64 /* ChatState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF2A80C2B8E011000941A64 /* ChatState+Observer.swift */; }; 4FF9B2682C6F697300A3B711 /* AttachmentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF9B2672C6F696B00A3B711 /* AttachmentDownloader.swift */; }; 4FF9B2692C6F697300A3B711 /* AttachmentDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF9B2672C6F696B00A3B711 /* AttachmentDownloader.swift */; }; 4FFB5EA02BA0507900F0454F /* Collection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFB5E9F2BA0507900F0454F /* Collection+Extensions.swift */; }; 4FFB5EA12BA0507900F0454F /* Collection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFB5E9F2BA0507900F0454F /* Collection+Extensions.swift */; }; + 4FFD1FD52EB9FB65009099C5 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FFD1FD42EB9FB65009099C5 /* StreamCore */; }; 6428DD5526201DCC0065DA1D /* BannerShowingConnectionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6428DD5426201DCC0065DA1D /* BannerShowingConnectionDelegate.swift */; }; 647F66D5261E22C200111B19 /* DemoConnectionBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F66D4261E22C200111B19 /* DemoConnectionBannerView.swift */; }; 648EC576261EF9D400B8F05F /* DemoAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648EC575261EF9D400B8F05F /* DemoAppCoordinator.swift */; }; @@ -5010,7 +4995,6 @@ buildActionMask = 2147483647; files = ( 437FCA1026D67CB40000223C /* StreamChat.framework in Frameworks */, - 4F110E462EA8E28A00273036 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5019,7 +5003,6 @@ buildActionMask = 2147483647; files = ( C121EC662746AD0E00023E4C /* StreamChat.framework in Frameworks */, - 4F110E482EA8E2BE00273036 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5030,7 +5013,6 @@ 82E6553C2B06785700D64906 /* StreamChatTestTools.framework in Frameworks */, 7908820625432B7200896F03 /* StreamChatUI.framework in Frameworks */, 82F714AB2B078AE800442A74 /* StreamSwiftTestHelpers in Frameworks */, - 4F110E502EA8E9FF00273036 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5042,7 +5024,6 @@ ADDFDE2B2779EC8A003B3B07 /* Atlantis in Frameworks */, C121EC5D2746AC8C00023E4C /* StreamChat.framework in Frameworks */, C1BE72732732CA62006EB51E /* Nuke in Frameworks */, - 4F110E4C2EA8E2FC00273036 /* StreamCore in Frameworks */, C121EC612746AC8C00023E4C /* StreamChatUI.framework in Frameworks */, ADCB576628A425D500B81AE8 /* Sentry in Frameworks */, ); @@ -5052,7 +5033,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4F110E4E2EA8E9C100273036 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5068,7 +5048,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4FECE0902E9392A3007D14F0 /* StreamCore in Frameworks */, + 4FFD1FD52EB9FB65009099C5 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5077,7 +5057,6 @@ buildActionMask = 2147483647; files = ( F8788F81261DE9B0006019DD /* StreamChatTestTools.framework in Frameworks */, - 4FE28A892EA9115A0035B93E /* StreamCore in Frameworks */, 799C9456247D59B1001F1104 /* StreamChat.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5087,7 +5066,6 @@ buildActionMask = 2147483647; files = ( 84748F8D2AC37F40007E3285 /* StreamChatUI.framework in Frameworks */, - 4F110E4A2EA8E2E800273036 /* StreamCore in Frameworks */, 84748F892AC37F40007E3285 /* StreamChat.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5099,7 +5077,6 @@ 82DCB3A92A4AE8FB00738933 /* StreamChat.framework in Frameworks */, A3BD486B281FD4500090D511 /* OHHTTPStubs in Frameworks */, 82DCB3AD2A4AE8FB00738933 /* StreamChatUI.framework in Frameworks */, - 4FE28A8B2EA9FC6E0035B93E /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5109,7 +5086,6 @@ files = ( 82120C302B6AB3B400347A35 /* StreamChat.framework in Frameworks */, 82120C312B6AB3B400347A35 /* StreamChatUI.framework in Frameworks */, - 4FE28A8D2EA9FC7F0035B93E /* StreamCore in Frameworks */, 827414272ACDE941009CD13C /* StreamChatTestMockServer.framework in Frameworks */, 827418212ACDE86F004A23DA /* StreamSwiftTestHelpers in Frameworks */, ); @@ -5119,7 +5095,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4FCB80B12EB2455A00908631 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5128,7 +5103,6 @@ buildActionMask = 2147483647; files = ( AC90839D268B120900ACFB8E /* StreamChat.framework in Frameworks */, - 4FE28A952EA9FF400035B93E /* StreamCore in Frameworks */, AC908398268B120300ACFB8E /* StreamChatUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5140,7 +5114,6 @@ C11B577229D4403800D5A248 /* StreamChat.framework in Frameworks */, C11B577129D4403800D5A248 /* Atlantis in Frameworks */, C11B577629D4403800D5A248 /* StreamChatUI.framework in Frameworks */, - 4FE28A972EA9FF460035B93E /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5160,7 +5133,6 @@ C1393361275F5D1E00225E7A /* Nuke in Frameworks */, F86C87F125F907CA0000BCA9 /* StreamChat.framework in Frameworks */, F86C87F225F907CA0000BCA9 /* StreamChatUI.framework in Frameworks */, - 4FE28A8F2EA9FF2B0035B93E /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5169,7 +5141,6 @@ buildActionMask = 2147483647; files = ( F8933BDB25FF569E0054BBFF /* StreamChat.framework in Frameworks */, - 4FE28A912EA9FF350035B93E /* StreamCore in Frameworks */, F8933BDC25FF569E0054BBFF /* StreamChatUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5179,7 +5150,6 @@ buildActionMask = 2147483647; files = ( F89C23EE25E522FA0082CA5B /* StreamChatUI.framework in Frameworks */, - 4FE28A932EA9FF3A0035B93E /* StreamCore in Frameworks */, F89C23ED25E520140082CA5B /* StreamChat.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -9926,7 +9896,6 @@ ); name = StreamChatUI; packageProductDependencies = ( - 4F110E472EA8E2BE00273036 /* StreamCore */, ); productName = StreamChatUI; productReference = 790881FD25432B7200896F03 /* StreamChatUI.framework */; @@ -9949,7 +9918,6 @@ name = StreamChatUITests; packageProductDependencies = ( 82F714AA2B078AE800442A74 /* StreamSwiftTestHelpers */, - 4F110E4F2EA8E9FF00273036 /* StreamCore */, ); productName = StreamChatUITests; productReference = 7908820525432B7200896F03 /* StreamChatUITests.xctest */; @@ -9979,7 +9947,6 @@ ADDFDE2A2779EC8A003B3B07 /* Atlantis */, C1B49B3A282283C100F4E89E /* GDPerformanceView-Swift */, ADCB576528A425D500B81AE8 /* Sentry */, - 4F110E4B2EA8E2FC00273036 /* StreamCore */, ); productName = DemoApp; productReference = 792DDA57256FB69E001DB91B /* ChatSample.app */; @@ -10001,7 +9968,6 @@ ); name = StreamChatTestTools; packageProductDependencies = ( - 4F110E4D2EA8E9C100273036 /* StreamCore */, ); productName = StreamChatTestTools; productReference = 793060E625778896005CF846 /* StreamChatTestTools.framework */; @@ -10042,7 +10008,7 @@ ); name = StreamChat; packageProductDependencies = ( - 4FECE08F2E9392A3007D14F0 /* StreamCore */, + 4FFD1FD42EB9FB65009099C5 /* StreamCore */, ); productName = Sources; productReference = 799C941B247D2F80001F1104 /* StreamChat.framework */; @@ -10064,7 +10030,6 @@ ); name = StreamChatTests; packageProductDependencies = ( - 4FE28A882EA9115A0035B93E /* StreamCore */, ); productName = StreamChatClientTests; productReference = 799C9451247D59B1001F1104 /* StreamChatTests.xctest */; @@ -10107,7 +10072,6 @@ name = StreamChatUITestsApp; packageProductDependencies = ( A3BD486A281FD4500090D511 /* OHHTTPStubs */, - 4FE28A8A2EA9FC6E0035B93E /* StreamCore */, ); productName = StreamChatUITestsApp; productReference = A34407BA27D8C33F0044F150 /* StreamChatUITestsApp.app */; @@ -10131,7 +10095,6 @@ name = StreamChatUITestsAppUITests; packageProductDependencies = ( 827418202ACDE86F004A23DA /* StreamSwiftTestHelpers */, - 4FE28A8C2EA9FC7F0035B93E /* StreamCore */, ); productName = StreamChatUITestsAppUITests; productReference = A34407DC27D8C3400044F150 /* StreamChatUITestsAppUITests.xctest */; @@ -10153,7 +10116,6 @@ ); name = StreamChatTestMockServer; packageProductDependencies = ( - 4FCB80B02EB2455A00908631 /* StreamCore */, ); productName = StreamChatUITestTools; productReference = A3A0C999283E952900B18DA4 /* StreamChatTestMockServer.framework */; @@ -10196,7 +10158,6 @@ name = EdgeCases; packageProductDependencies = ( C11B577029D4403800D5A248 /* Atlantis */, - 4FE28A962EA9FF460035B93E /* StreamCore */, ); productName = EdgeCases; productReference = C11B575B29D43FD800D5A248 /* EdgeCases.app */; @@ -10273,7 +10234,6 @@ name = Messenger; packageProductDependencies = ( C1393360275F5D1E00225E7A /* Nuke */, - 4FE28A8E2EA9FF2B0035B93E /* StreamCore */, ); productName = MessengerClone; productReference = F86C87B225F906630000BCA9 /* Messenger.app */; @@ -15758,75 +15718,7 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 4F110E452EA8E28A00273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4F110E472EA8E2BE00273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4F110E492EA8E2E800273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4F110E4B2EA8E2FC00273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4F110E4D2EA8E9C100273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4F110E4F2EA8E9FF00273036 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4FCB80B02EB2455A00908631 /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - productName = StreamCore; - }; - 4FE28A882EA9115A0035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A8A2EA9FC6E0035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A8C2EA9FC7F0035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A8E2EA9FF2B0035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A902EA9FF350035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A922EA9FF3A0035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A942EA9FF400035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FE28A962EA9FF460035B93E /* StreamCore */ = { - isa = XCSwiftPackageProductDependency; - package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; - productName = StreamCore; - }; - 4FECE08F2E9392A3007D14F0 /* StreamCore */ = { + 4FFD1FD42EB9FB65009099C5 /* StreamCore */ = { isa = XCSwiftPackageProductDependency; package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; productName = StreamCore; From e267b667faa49535b9e700bf5eeda4f1281ee146 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Wed, 5 Nov 2025 09:33:50 +0200 Subject: [PATCH 17/17] Use StreamCore 0.5.0 --- Package.swift | 2 +- StreamChat.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 00f8e62ee88..7a9b24fdf11 100644 --- a/Package.swift +++ b/Package.swift @@ -29,7 +29,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", exact: "1.0.0"), - .package(url: "https://github.com/GetStream/stream-core-swift.git", branch: "chat-web-socket-client") + .package(url: "https://github.com/GetStream/stream-core-swift.git", exact: "0.5.0") ], targets: [ .target( diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 1457b923203..da8970fac69 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -15663,8 +15663,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GetStream/stream-core-swift"; requirement = { - branch = "chat-web-socket-client"; - kind = branch; + kind = exactVersion; + version = 0.5.0; }; }; A3BD4869281FD4500090D511 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = {