diff --git a/DemoApp/Extensions/StreamCore+Extensions.swift b/DemoApp/Extensions/StreamCore+Extensions.swift new file mode 100644 index 00000000000..39d621127b6 --- /dev/null +++ b/DemoApp/Extensions/StreamCore+Extensions.swift @@ -0,0 +1,3 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// diff --git a/Package.swift b/Package.swift index 2b052701e53..06e49900236 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", exact: "0.4.1") + .package(url: "https://github.com/GetStream/stream-core-swift.git", branch: "chat-web-socket-migration") ], targets: [ .target( diff --git a/Sources/StreamChat/APIClient/RequestEncoder.swift b/Sources/StreamChat/APIClient/RequestEncoder.swift index 28c1b809929..8464b0d3285 100644 --- a/Sources/StreamChat/APIClient/RequestEncoder.swift +++ b/Sources/StreamChat/APIClient/RequestEncoder.swift @@ -304,7 +304,6 @@ protocol ConnectionDetailsProviderDelegate: AnyObject { } public extension ClientError { - final class InvalidURL: ClientError, @unchecked Sendable {} final class InvalidJSON: ClientError, @unchecked Sendable {} final class MissingConnectionId: ClientError, @unchecked Sendable {} } diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 50c0c9ae671..31e1038053d 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -6,7 +6,7 @@ import Foundation extension ChatClient { /// An object containing all dependencies of `Client` - struct Environment: Sendable { + struct Environment: @unchecked Sendable { var apiClientBuilder: @Sendable ( _ sessionConfiguration: URLSessionConfiguration, _ requestEncoder: RequestEncoder, @@ -25,15 +25,15 @@ extension ChatClient { var webSocketClientBuilder: (@Sendable ( _ sessionConfiguration: URLSessionConfiguration, - _ requestEncoder: RequestEncoder, _ eventDecoder: AnyEventDecoder, _ notificationCenter: EventNotificationCenter ) -> WebSocketClient)? = { - WebSocketClient( + return 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?) -> EventPersistentNotificationCenter = { EventPersistentNotificationCenter(database: $0, manualEventHandler: $1) } var internetConnection: @Sendable (_ center: NotificationCenter, _ monitor: InternetConnectionMonitor) -> InternetConnection = { InternetConnection(notificationCenter: $0, monitor: $1) @@ -76,16 +76,18 @@ extension ChatClient { var connectionRepositoryBuilder: @Sendable ( _ isClientInActiveMode: Bool, _ syncRepository: SyncRepository, + _ webSocketRequestEncoder: RequestEncoder?, _ webSocketClient: WebSocketClient?, _ apiClient: APIClient, - _ timerType: Timer.Type + _ timerType: TimerScheduling.Type ) -> ConnectionRepository = { ConnectionRepository( isClientInActiveMode: $0, syncRepository: $1, - webSocketClient: $2, - apiClient: $3, - timerType: $4 + webSocketRequestEncoder: $2, + webSocketClient: $3, + apiClient: $4, + timerType: $5 ) } @@ -103,14 +105,13 @@ extension ChatClient { } } - var timerType: Timer.Type = DefaultTimer.self + var timerType: TimerScheduling.Type = DefaultTimer.self var tokenExpirationRetryStrategy: RetryStrategy = DefaultRetryStrategy() 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 + reconnectionTimerType: StreamCore.DefaultTimer.self, + keepConnectionAliveInBackground: $4 ) } @@ -132,7 +132,7 @@ extension ChatClient { _ databaseContainer: DatabaseContainer, _ connectionRepository: ConnectionRepository, _ tokenExpirationRetryStrategy: RetryStrategy, - _ timerType: Timer.Type + _ timerType: TimerScheduling.Type ) -> AuthenticationRepository = { AuthenticationRepository( apiClient: $0, diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 204922a8b92..d86a34cf94c 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: EventPersistentNotificationCenter /// 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 webSocketRequestEncoder: 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.webSocketRequestEncoder = 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 @@ -735,6 +736,9 @@ extension ChatClient: ConnectionStateDelegate { } case .connected: reconnectionTimeoutHandler?.stop() + syncRepository.syncLocalState { + log.info("Local state sync completed", subsystems: .offlineSupport) + } default: break } diff --git a/Sources/StreamChat/ChatClientFactory.swift b/Sources/StreamChat/ChatClientFactory.swift index 73ce9239706..18857d411cd 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 ) -> WebSocketClient? { environment.webSocketClientBuilder?( urlSessionConfiguration, - requestEncoder, EventDecoder(), eventNotificationCenter ) @@ -114,7 +112,7 @@ class ChatClientFactory { func makeEventNotificationCenter( databaseContainer: DatabaseContainer, currentUserId: @escaping () -> UserId? - ) -> EventNotificationCenter { + ) -> EventPersistentNotificationCenter { let center = environment.notificationCenterBuilder(databaseContainer, nil) let middlewares: [EventMiddleware] = [ EventDataProcessorMiddleware(), 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/Config/Token.swift b/Sources/StreamChat/Config/Token.swift index 29a429beb3a..c3cb50dc49f 100644 --- a/Sources/StreamChat/Config/Token.swift +++ b/Sources/StreamChat/Config/Token.swift @@ -72,10 +72,6 @@ public extension Token { } } -extension ClientError { - public final class InvalidToken: ClientError, @unchecked Sendable {} -} - private extension String { var jwtPayload: [String: Any]? { let parts = split(separator: ".") diff --git a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift index b4ca8c8cf0f..07267d97a8d 100644 --- a/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift +++ b/Sources/StreamChat/Controllers/PollController/PollVoteListController.swift @@ -163,16 +163,26 @@ public class PollVoteListController: DataController, DelegateCallable, DataStore super.init() eventsObserver = client.subscribe { [weak self] event in guard let self else { return } - var vote: PollVote? - if let event = event as? PollVoteCastedEvent { - vote = event.vote - } else if let event = event as? PollVoteChangedEvent { - vote = event.vote - } - guard let vote else { return } - if vote.isAnswer == true && self.query.pollId == vote.pollId && self.query.optionId == nil { - self.pollsRepository.link(pollVote: vote, to: query) - } + self.didReceiveEvent(event) + } + } + + func didReceiveEvent(_ event: Event) { + var vote: PollVote? + if let event = event as? PollVoteCastedEvent { + vote = event.vote + } else if let event = event as? PollVoteChangedEvent { + vote = event.vote + } + guard let vote else { return } + if vote.isAnswer == true + && query.pollId == vote.pollId + && query.optionId == nil { + pollsRepository.link(pollVote: vote, to: query) + } else if vote.isAnswer == false + && query.pollId == vote.pollId + && query.optionId == vote.optionId { + pollsRepository.link(pollVote: vote, to: query) } } @@ -270,24 +280,3 @@ extension PollVoteListController { } } } - -extension PollVoteListController: EventsControllerDelegate { - public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { - var vote: PollVote? - if let event = event as? PollVoteCastedEvent { - vote = event.vote - } else if let event = event as? PollVoteChangedEvent { - vote = event.vote - } - guard let vote else { return } - if vote.isAnswer == true - && query.pollId == vote.pollId - && query.optionId == nil { - pollsRepository.link(pollVote: vote, to: query) - } else if vote.isAnswer == false - && query.pollId == vote.pollId - && query.optionId == vote.optionId { - pollsRepository.link(pollVote: vote, to: query) - } - } -} diff --git a/Sources/StreamChat/Errors/ClientError.swift b/Sources/StreamChat/Errors/ClientError.swift index 4d664daddff..37a56bd7ddd 100644 --- a/Sources/StreamChat/Errors/ClientError.swift +++ b/Sources/StreamChat/Errors/ClientError.swift @@ -4,81 +4,12 @@ import Foundation -/// A Client error. -public class ClientError: Error, CustomStringConvertible, @unchecked Sendable { - public struct Location: Equatable { - public let file: String - public let line: Int - } - - /// The file and line number which emitted the error. - public let location: Location? - - public let message: String? - - /// An underlying error. - public let underlyingError: Error? - - public var errorDescription: String? { underlyingError.map(String.init(describing:)) } - - /// The error payload if the underlying error comes from a server error. - public var errorPayload: ErrorPayload? { underlyingError as? ErrorPayload } - - /// Retrieve the localized description for this error. - public var localizedDescription: String { message ?? errorDescription ?? "" } - - public private(set) lazy var description = "Error \(type(of: self)) in \(location?.file ?? ""):\(location?.line ?? 0)" - + (localizedDescription.isEmpty ? "" : " -> ") - + localizedDescription - - /// A client error based on an external general error. - /// - Parameters: - /// - error: an external error. - /// - file: a file name source of an error. - /// - line: a line source of an error. - public init(with error: Error? = nil, _ file: StaticString = #file, _ line: UInt = #line) { - message = nil - underlyingError = error - location = .init(file: "\(file)", line: Int(line)) - } - - /// An error based on a message. - /// - Parameters: - /// - message: an error message. - /// - file: a file name source of an error. - /// - line: a line source of an error. - public init(_ message: String, _ file: StaticString = #file, _ line: UInt = #line) { - self.message = message - location = .init(file: "\(file)", line: Int(line)) - underlyingError = nil - } -} - -extension ClientError { - /// An unexpected error. - public final class Unexpected: ClientError, @unchecked Sendable {} - - /// An unknown error. - public final class Unknown: ClientError, @unchecked Sendable {} -} - -// This should probably live only in the test target since it's not "true" equatable -extension ClientError: Equatable { - public static func == (lhs: ClientError, rhs: ClientError) -> Bool { - type(of: lhs) == type(of: rhs) - && String(describing: lhs.underlyingError) == String(describing: rhs.underlyingError) - && String(describing: lhs.localizedDescription) == String(describing: rhs.localizedDescription) - } -} - extension ClientError { /// Returns `true` the stream code determines that the token is expired. var isExpiredTokenError: Bool { (underlyingError as? ErrorPayload)?.isExpiredTokenError == true } - /// Returns `true` if underlaying error is `ErrorPayload` with code is inside invalid token codes range. - var isInvalidTokenError: Bool { - (underlyingError as? ErrorPayload)?.isInvalidTokenError == true - } + /// The error payload if the underlying error comes from a server error. + public var errorPayload: ErrorPayload? { underlyingError as? ErrorPayload } } diff --git a/Sources/StreamChat/Errors/ErrorPayload.swift b/Sources/StreamChat/Errors/ErrorPayload.swift deleted file mode 100644 index 825efa0956c..00000000000 --- a/Sources/StreamChat/Errors/ErrorPayload.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A parsed server response error. -public struct ErrorPayload: LocalizedError, Codable, CustomDebugStringConvertible, Equatable { - private enum CodingKeys: String, CodingKey { - case code - case message - case statusCode = "StatusCode" - } - - /// An error code. - public let code: Int - /// A message. - public let message: String - /// An HTTP status code. - public let statusCode: Int - - public var errorDescription: String? { - "Error #\(code): \(message)" - } - - public var debugDescription: String { - "\(String(describing: Self.self))(code: \(code), message: \"\(message)\", statusCode: \(statusCode)))." - } -} - -/// https://getstream.io/chat/docs/ios-swift/api_errors_response/ -enum StreamErrorCode { - /// Usually returned when trying to perform an API call without a token. - static let accessKeyInvalid = 2 - static let expiredToken = 40 - static let notYetValidToken = 41 - static let invalidTokenDate = 42 - static let invalidTokenSignature = 43 -} - -extension ErrorPayload { - /// Returns `true` if the code determines that the token is expired. - var isExpiredTokenError: Bool { - code == StreamErrorCode.expiredToken - } - - /// Returns `true` if code is within invalid token codes range. - var isInvalidTokenError: Bool { - ClosedRange.tokenInvalidErrorCodes ~= code || code == StreamErrorCode.accessKeyInvalid - } - - /// Returns `true` if status code is within client error codes range. - var isClientError: Bool { - ClosedRange.clientErrorCodes ~= statusCode - } -} - -extension ClosedRange where Bound == Int { - /// The error codes for token-related errors. Typically, a refreshed token is required to recover. - static let tokenInvalidErrorCodes: Self = StreamErrorCode.notYetValidToken...StreamErrorCode.invalidTokenSignature - - /// The range of HTTP request status codes for client errors. - static let clientErrorCodes: Self = 400...499 -} - -/// A parsed server response error detail. -public struct ErrorPayloadDetail: LocalizedError, Codable, Equatable { - private enum CodingKeys: String, CodingKey { - case code - case messages - } - - /// An error code. - public let code: Int - /// An array of message strings that better describe the error detail. - public let messages: [String] -} diff --git a/Sources/StreamChat/Query/Filter.swift b/Sources/StreamChat/Query/Filter.swift index aa382f1529c..1bfe4f749c7 100644 --- a/Sources/StreamChat/Query/Filter.swift +++ b/Sources/StreamChat/Query/Filter.swift @@ -592,14 +592,14 @@ private struct FilterRightSide: Decodable { self.operator = container.allKeys.first!.stringValue var value: FilterValue? - if let dateValue = try? container.decode(Date.self, forKey: key) { - value = dateValue - } else if let stringValue = try? container.decode(String.self, forKey: key) { - value = stringValue - } else if let intValue = try? container.decode(Int.self, forKey: key) { + if let intValue = try? container.decode(Int.self, forKey: key) { value = intValue } else if let doubleValue = try? container.decode(Double.self, forKey: key) { value = doubleValue + } else if let dateValue = try? container.decode(Date.self, forKey: key) { + value = dateValue + } else if let stringValue = try? container.decode(String.self, forKey: key) { + value = stringValue } else if let boolValue = try? container.decode(Bool.self, forKey: key) { value = boolValue } else if let stringArray = try? container.decode([String].self, forKey: key) { diff --git a/Sources/StreamChat/Repositories/AuthenticationRepository.swift b/Sources/StreamChat/Repositories/AuthenticationRepository.swift index 4611e1d751d..dead7ffaab5 100644 --- a/Sources/StreamChat/Repositories/AuthenticationRepository.swift +++ b/Sources/StreamChat/Repositories/AuthenticationRepository.swift @@ -97,14 +97,14 @@ class AuthenticationRepository: @unchecked Sendable { private let apiClient: APIClient private let databaseContainer: DatabaseContainer private let connectionRepository: ConnectionRepository - private let timerType: Timer.Type + private let timerType: TimerScheduling.Type init( apiClient: APIClient, databaseContainer: DatabaseContainer, connectionRepository: ConnectionRepository, tokenExpirationRetryStrategy: RetryStrategy, - timerType: Timer.Type + timerType: TimerScheduling.Type ) { self.apiClient = apiClient self.databaseContainer = databaseContainer diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index 1f091840904..976f48cb561 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -24,19 +24,23 @@ class ConnectionRepository: @unchecked Sendable { let isClientInActiveMode: Bool private let syncRepository: SyncRepository + private let webSocketRequestEncoder: RequestEncoder? private let webSocketClient: WebSocketClient? + private(set) var webSocketConnectEndpoint: Endpoint? private let apiClient: APIClient - private let timerType: Timer.Type + private let timerType: TimerScheduling.Type init( isClientInActiveMode: Bool, syncRepository: SyncRepository, + webSocketRequestEncoder: RequestEncoder?, webSocketClient: WebSocketClient?, apiClient: APIClient, - timerType: Timer.Type + timerType: TimerScheduling.Type ) { self.isClientInActiveMode = isClientInActiveMode self.syncRepository = syncRepository + self.webSocketRequestEncoder = webSocketRequestEncoder 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 = .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 = .webSocketConnect(userInfo: UserInfo(id: currentUserId)) + } + + private func updateWebSocketConnectURLRequest() { + guard let webSocketClient, let webSocketRequestEncoder, let webSocketConnectEndpoint else { return } + let request: URLRequest? = { + do { + return try webSocketRequestEncoder.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 ) { + // Publish Connection event with the new state + let event = ConnectionStatusUpdated(webSocketConnectionState: state) + if event.connectionStatus != connectionStatus { + webSocketClient?.eventNotificationCenter.process(event, postNotification: true) + } + connectionStatus = .init(webSocketConnectionState: state) // We should notify waiters if connectionId was obtained (i.e. state is .connected) @@ -133,9 +158,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 +171,7 @@ class ConnectionRepository: @unchecked Sendable { case .initialized, .connecting, .disconnecting, - .waitingForConnectionId: + .authenticating: shouldNotifyConnectionIdWaiters = false connectionId = nil } diff --git a/Sources/StreamChat/StateLayer/Chat.swift b/Sources/StreamChat/StateLayer/Chat.swift index 738a947cd53..f81d6ab5348 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 EventPersistentNotificationCenter.channelFilter(cid: cid, event: event) else { return } callback(event) } } diff --git a/Sources/StreamChat/Utils/AllocatedUnfairLock.swift b/Sources/StreamChat/Utils/AllocatedUnfairLock.swift deleted file mode 100644 index 4929715f1df..00000000000 --- a/Sources/StreamChat/Utils/AllocatedUnfairLock.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import os - -@available(iOS, introduced: 13.0, deprecated: 16.0, message: "Use OSAllocatedUnfairLock instead") -final class AllocatedUnfairLock: @unchecked Sendable { - private let lock: UnsafeMutablePointer - private nonisolated(unsafe) var _value: State - - init(_ initialState: State) { - lock = UnsafeMutablePointer.allocate(capacity: 1) - lock.initialize(to: os_unfair_lock()) - _value = initialState - } - - deinit { - lock.deinitialize(count: 1) - lock.deallocate() - } - - @discardableResult - func withLock(_ body: (inout State) throws -> R) rethrows -> R { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return try body(&_value) - } - - var value: State { - get { - withLock { $0 } - } - set { - withLock { $0 = newValue } - } - } -} diff --git a/Sources/StreamChat/Utils/Atomic.swift b/Sources/StreamChat/Utils/Atomic.swift deleted file mode 100644 index cd8f93a4527..00000000000 --- a/Sources/StreamChat/Utils/Atomic.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A mutable thread safe variable. -/// -/// - Warning: Be aware that accessing and setting a value are two distinct operations, so using operators like `+=` results -/// in two separate atomic operations. To work around this issue, you can access the wrapper directly and use the -/// `mutate(_ changes:)` method: -/// ``` -/// // Correct -/// atomicValue = 1 -/// let value = atomicValue -/// -/// atomicValue += 1 // Incorrect! Accessing and setting a value are two atomic operations. -/// _atomicValue.mutate { $0 += 1 } // Correct -/// _atomicValue { $0 += 1 } // Also possible -/// ``` -/// -/// - Note: Even though the value guarded by `Atomic` is thread-safe, the `Atomic` class itself is not. Mutating the instance -/// itself from multiple threads can cause a crash. - -@propertyWrapper -public class Atomic: @unchecked Sendable { - public var wrappedValue: T { - get { - var currentValue: T! - mutate { currentValue = $0 } - return currentValue - } - - set { - mutate { $0 = newValue } - } - } - - private let lock = NSRecursiveLock() - private var _wrappedValue: T - - /// Update the value safely. - /// - Parameter changes: a block with changes. It should return a new value. - public func mutate(_ changes: (_ value: inout T) -> Void) { - lock.lock() - changes(&_wrappedValue) - lock.unlock() - } - - /// Update the value safely. - /// - Parameter changes: a block with changes. It should return a new value. - public func callAsFunction(_ changes: (_ value: inout T) -> Void) { - mutate(changes) - } - - public init(wrappedValue: T) { - _wrappedValue = wrappedValue - } -} diff --git a/Sources/StreamChat/Utils/Codable+Extensions.swift b/Sources/StreamChat/Utils/Codable+Extensions.swift index be11edd8acc..4ce3d156bc2 100644 --- a/Sources/StreamChat/Utils/Codable+Extensions.swift +++ b/Sources/StreamChat/Utils/Codable+Extensions.swift @@ -4,202 +4,6 @@ import Foundation -// MARK: - JSONDecoder Stream - -final class StreamJSONDecoder: JSONDecoder, @unchecked Sendable { - let iso8601formatter: ISO8601DateFormatter - let dateCache: NSCache - - override convenience init() { - let iso8601formatter = ISO8601DateFormatter() - iso8601formatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] - - let dateCache = NSCache() - dateCache.countLimit = 5000 // We cache at most 5000 dates, which gives good enough performance - - self.init( - dateFormatter: iso8601formatter, - dateCache: dateCache - ) - } - - init( - dateFormatter: ISO8601DateFormatter, - dateCache: NSCache - ) { - iso8601formatter = dateFormatter - self.dateCache = dateCache - - super.init() - - dateDecodingStrategy = .custom { [weak self] decoder throws -> Date in - let container = try decoder.singleValueContainer() - let dateString: String = try container.decode(String.self) - - if let date = self?.dateCache.object(forKey: dateString as NSString) { - return date.bridgeDate - } - - if let date = self?.iso8601formatter.dateWithMicroseconds(from: dateString) { - self?.dateCache.setObject(date.bridgeDate, forKey: dateString as NSString) - return date - } - - if let date = DateFormatter.Stream.rfc3339Date(from: dateString) { - self?.dateCache.setObject(date.bridgeDate, forKey: dateString as NSString) - return date - } - - // Fail - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(dateString)") - } - } -} - -extension StreamJSONDecoder { - /// A convenience method returning RawJSON dictionary. - func decodeRawJSON(from data: Data?) throws -> [String: RawJSON] { - guard let data, !data.isEmpty else { return [:] } - let rawJSON = try decode([String: RawJSON].self, from: data) - return rawJSON - } -} - -extension JSONDecoder { - /// A default `JSONDecoder`. - static let `default`: JSONDecoder = stream - - /// A Stream Chat JSON decoder. - static let stream: StreamJSONDecoder = { - StreamJSONDecoder() - }() -} - -// MARK: - JSONEncoder Stream - -extension JSONEncoder { - /// A default `JSONEncoder`. - static let `default`: JSONEncoder = stream - /// A default gzip `JSONEncoder`. - static let defaultGzip: JSONEncoder = streamGzip - - /// A Stream Chat JSON encoder. - static let stream: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .stream - return encoder - }() - - /// A Stream Chat JSON encoder with a gzipped content. - static let streamGzip: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dataEncodingStrategy = .gzip - encoder.dateEncodingStrategy = .stream - return encoder - }() -} - -extension JSONEncoder.DataEncodingStrategy { - // Gzip data encoding. - static var gzip: JSONEncoder.DataEncodingStrategy { - .custom { data, encoder throws in - var container = encoder.singleValueContainer() - let gzippedData = try data.gzipped() - try container.encode(gzippedData) - } - } -} - -extension JSONEncoder.DateEncodingStrategy { - /// A Stream encoding for the custom ISO8601 date. - static var stream: JSONEncoder.DateEncodingStrategy { - .custom { date, encoder throws in - var container = encoder.singleValueContainer() - try container.encode(DateFormatter.Stream.rfc3339DateString(from: date)) - } - } -} - -// MARK: - Date Formatter Helper - -extension DateFormatter { - /// Stream Chat date formatters. - enum Stream { - // Creates and returns a date object from the specified RFC3339 formatted string representation. - /// - /// - Parameter string: The RFC3339 formatted string representation of a date. - /// - Returns: A date object, or nil if no valid date was found. - static func rfc3339Date(from string: String) -> Date? { - let RFC3339TimezoneWrapper = "Z" - let uppercaseString = string.uppercased() - let removedTimezoneWrapperString = uppercaseString.replacingOccurrences(of: RFC3339TimezoneWrapper, with: "-0000") - return gmtDateFormatters.lazy.compactMap { $0.date(from: removedTimezoneWrapperString) }.first - } - - /// Creates and returns an RFC 3339 formatted string representation of the specified date. - /// - /// - Parameter date: The date to be represented. - /// - Returns: A user-readable string representing the date. - static func rfc3339DateString(from date: Date) -> String? { - let nanosecondsInMillisecond = 1_000_000 - - var gmtCalendar = Calendar(identifier: .iso8601) - if let zeroTimezone = TimeZone(secondsFromGMT: 0) { - gmtCalendar.timeZone = zeroTimezone - } - - let components = gmtCalendar.dateComponents([.nanosecond], from: date) - // If nanoseconds is more that 1 millisecond, use format with fractional seconds - guard let nanoseconds = components.nanosecond, - nanoseconds >= nanosecondsInMillisecond - else { - return dateFormatterWithoutFractional.string(from: date) - } - - return dateFormatterWithFractional.string(from: date) - } - - // Formats according to samples - // 2000-12-19T16:39:57-0800 - // 1934-01-01T12:00:27.87+0020 - // 1989-01-01T12:00:27 - private static let gmtDateFormatters: [DateFormatter] = [ - "yyyy'-'MM'-'dd'T'HH':'mm':'ssZZZ", - "yyyy'-'MM'-'dd'T'HH':'mm':'ss.SSSZZZ", - "yyyy'-'MM'-'dd'T'HH':'mm':'ss" - ].map(makeDateFormatter) - - private static let dateFormatterWithoutFractional = makeDateFormatter(dateFormat: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") - private static let dateFormatterWithFractional = makeDateFormatter(dateFormat: "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX") - - private static func makeDateFormatter(dateFormat: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = dateFormat - return formatter - } - } -} - -extension ISO8601DateFormatter { - func dateWithMicroseconds(from string: String) -> Date? { - guard let date = date(from: string) else { return nil } - // Manually parse microseconds and nanoseconds, because ISO8601DateFormatter is limited to ms. - // Note that Date's timeIntervalSince1970 rounds to 0.000_000_1 - guard let index = string.lastIndex(of: ".") else { return date } - let range = string.suffix(from: index) - .dropFirst(4) // . and ms part - .dropLast() // Z - var fractionWithoutMilliseconds = String(range) - if fractionWithoutMilliseconds.count < 3 { - fractionWithoutMilliseconds = fractionWithoutMilliseconds.padding(toLength: 3, withPad: "0", startingAt: 0) - } - guard let microseconds = TimeInterval("0.000".appending(fractionWithoutMilliseconds)) else { return date } - return Date(timeIntervalSince1970: date.timeIntervalSince1970 + microseconds) - } -} - // MARK: - Helper AnyEncodable struct AnyEncodable: Encodable, Sendable { diff --git a/Sources/StreamChat/Utils/EventBatcher.swift b/Sources/StreamChat/Utils/EventBatcher.swift deleted file mode 100644 index 81227e1ad4d..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: Timer.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: Timer.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: Timer.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/Utils/InternetConnection/Error+InternetNotAvailable.swift b/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift index 19ff50d61f3..759aa1947da 100644 --- a/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift +++ b/Sources/StreamChat/Utils/InternetConnection/Error+InternetNotAvailable.swift @@ -41,14 +41,6 @@ extension Error { return false } - var isRateLimitError: Bool { - if let error = (self as? ClientError)?.underlyingError as? ErrorPayload, - error.statusCode == 429 { - return true - } - return false - } - var isSocketNotConnectedError: Bool { has(parameters: (NSPOSIXErrorDomain, 57)) } 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/Sources/StreamChat/Utils/StreamConcurrency.swift b/Sources/StreamChat/Utils/StreamConcurrency.swift deleted file mode 100644 index 3c83dd990bb..00000000000 --- a/Sources/StreamChat/Utils/StreamConcurrency.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -enum StreamConcurrency { - /// Synchronously performs the provided action on the main thread. - /// - /// Used for ensuring that we are on the main thread when compiler can't know it. For example, - /// controller completion handlers by default are called from main thread, but one can - /// configure controller to use background thread for completions instead. - /// - /// - Important: It is safe to call from any thread. It does not deadlock if we are already on the main thread. - /// - Important: Prefer Task { @MainActor if possible. - static func onMain(_ action: @MainActor () throws -> T) rethrows -> T where T: Sendable { - if Thread.current.isMainThread { - return try MainActor.assumeIsolated { - try action() - } - } else { - // We use sync here, because this function supports returning a value. - return try DispatchQueue.main.sync { - try action() - } - } - } -} diff --git a/Sources/StreamChat/Utils/Timers.swift b/Sources/StreamChat/Utils/Timers.swift deleted file mode 100644 index e118b0ae370..00000000000 --- a/Sources/StreamChat/Utils/Timers.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -protocol Timer { - /// Schedules a new timer. - /// - /// - Parameters: - /// - timeInterval: The number of seconds after which the timer fires. - /// - queue: The queue on which the `onFire` callback is called. - /// - onFire: Called when the timer fires. - /// - Returns: `TimerControl` where you can cancel the timer. - @discardableResult - static func schedule(timeInterval: TimeInterval, queue: DispatchQueue, onFire: @escaping () -> Void) -> TimerControl - - /// Schedules a new repeating timer. - /// - /// - Parameters: - /// - timeInterval: The number of seconds between timer fires. - /// - queue: The queue on which the `onFire` callback is called. - /// - onFire: Called when the timer fires. - /// - Returns: `RepeatingTimerControl` where you can suspend and resume the timer. - static func scheduleRepeating( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> RepeatingTimerControl - - /// Returns the current date and time. - static func currentTime() -> Date -} - -extension Timer { - static func currentTime() -> Date { - Date() - } -} - -/// Allows resuming and suspending of a timer. -protocol RepeatingTimerControl: Sendable { - /// Resumes the timer. - func resume() - - /// Pauses the timer. - func suspend() -} - -/// Allows cancelling a timer. -protocol TimerControl: Sendable { - /// Cancels the timer. - func cancel() -} - -extension DispatchWorkItem: TimerControl {} -extension DispatchWorkItem: @retroactive @unchecked Sendable {} - -/// Default real-world implementations of timers. -struct DefaultTimer: Timer { - @discardableResult - static func schedule( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> TimerControl { - let worker = DispatchWorkItem(block: onFire) - queue.asyncAfter(deadline: .now() + timeInterval, execute: worker) - return worker - } - - static func scheduleRepeating( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> RepeatingTimerControl { - RepeatingTimer(timeInterval: timeInterval, queue: queue, onFire: onFire) - } -} - -private final class RepeatingTimer: RepeatingTimerControl { - private enum State { - case suspended - case resumed - } - - private let queue = DispatchQueue(label: "io.getstream.repeating-timer") - private nonisolated(unsafe) var state: State = .suspended - #if compiler(>=6.1) - private let timer: DispatchSourceTimer - #else - private nonisolated(unsafe) let timer: DispatchSourceTimer - #endif - - init(timeInterval: TimeInterval, queue: DispatchQueue, onFire: @escaping () -> Void) { - timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + .milliseconds(Int(timeInterval)), repeating: timeInterval, leeway: .seconds(1)) - timer.setEventHandler(handler: onFire) - } - - deinit { - timer.setEventHandler {} - timer.cancel() - // If the timer is suspended, calling cancel without resuming - // triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902 - if state == .suspended { - timer.resume() - } - } - - func resume() { - queue.async { - if self.state == .resumed { - return - } - - self.state = .resumed - self.timer.resume() - } - } - - func suspend() { - queue.async { - if self.state == .suspended { - return - } - - self.state = .suspended - self.timer.suspend() - } - } -} 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/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/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 19683613f17..00000000000 --- a/Sources/StreamChat/WebSocketClient/Engine/WebSocketEngine.swift +++ /dev/null @@ -1,51 +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) -} - -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 - ) - } - } -} diff --git a/Sources/StreamChat/WebSocketClient/EventMiddlewares/TypingStartCleanupMiddleware.swift b/Sources/StreamChat/WebSocketClient/EventMiddlewares/TypingStartCleanupMiddleware.swift index 3a730508409..8e6aeb8b977 100644 --- a/Sources/StreamChat/WebSocketClient/EventMiddlewares/TypingStartCleanupMiddleware.swift +++ b/Sources/StreamChat/WebSocketClient/EventMiddlewares/TypingStartCleanupMiddleware.swift @@ -15,7 +15,7 @@ class TypingStartCleanupMiddleware: EventMiddleware { /// after `start typing` event. let emitEvent: (Event) -> Void /// A timer type. - var timer: Timer.Type = DefaultTimer.self + var timer: TimerScheduling.Type = DefaultTimer.self /// A list of timers per user id. @Atomic private var typingEventTimeoutTimerControls: [UserId: TimerControl] = [:] @@ -34,7 +34,7 @@ class TypingStartCleanupMiddleware: EventMiddleware { return event } - _typingEventTimeoutTimerControls { + _typingEventTimeoutTimerControls.mutate { $0[typingEvent.user.id]?.cancel() $0[typingEvent.user.id] = nil diff --git a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift index 62866c27b23..b632a1a03b2 100644 --- a/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift +++ b/Sources/StreamChat/WebSocketClient/Events/ConnectionEvents.swift @@ -32,17 +32,20 @@ public final class HealthCheckEvent: ConnectionEvent, EventDTO, Sendable { channel: nil ) } + + public func healthcheck() -> HealthCheckInfo? { + HealthCheckInfo(connectionId: connectionId) + } } -/// 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.). +/// 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 { /// The current connection status of `Client` public let connectionStatus: ConnectionStatus - + // Underlying WebSocketConnectionState let webSocketConnectionState: WebSocketConnectionState - + init(webSocketConnectionState: WebSocketConnectionState) { connectionStatus = .init(webSocketConnectionState: webSocketConnectionState) self.webSocketConnectionState = webSocketConnectionState diff --git a/Sources/StreamChat/WebSocketClient/Events/Event.swift b/Sources/StreamChat/WebSocketClient/Events/Event.swift index 5ceceb2a191..dd71aa2493f 100644 --- a/Sources/StreamChat/WebSocketClient/Events/Event.swift +++ b/Sources/StreamChat/WebSocketClient/Events/Event.swift @@ -4,15 +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: "") - } -} - /// An internal protocol marking the Events carrying the payload. This payload can be then used for additional work, /// i.e. for storing the data to the database. protocol EventDTO: Event { diff --git a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift index 2ccc65e4d3d..9fd4cb830ba 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventDecoder.swift @@ -25,29 +25,6 @@ extension ClientError { public final class IgnoredEventType: ClientError, @unchecked Sendable { override public var localizedDescription: String { "The incoming event type is not supported. Ignoring." } } - - public final class EventDecoding: ClientError, @unchecked Sendable { - override init(_ message: String, _ file: StaticString = #file, _ line: UInt = #line) { - super.init(message, file, line) - } - - init(missingValue: String, for type: T.Type, _ file: StaticString = #file, _ line: UInt = #line) { - super.init("`\(missingValue)` field can't be `nil` for the `\(type)` event.", file, line) - } - - init(missingValue: String, for type: EventType, _ file: StaticString = #file, _ line: UInt = #line) { - super.init("`\(missingValue)` field can't be `nil` for the `\(type.rawValue)` event.", file, line) - } - - init(failedParsingValue: String, for type: EventType, with error: Error, _ file: StaticString = #file, _ line: UInt = #line) { - super.init("`\(failedParsingValue)` failed to parse for the `\(type.rawValue)` event. Error: \(error)", file, line) - } - } -} - -/// A type-erased wrapper protocol for `EventDecoder`. -protocol AnyEventDecoder { - func decode(from: Data) throws -> Event } extension EventDecoder: AnyEventDecoder {} diff --git a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift index 1c9a533cb20..1dfa0847fd3 100644 --- a/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift +++ b/Sources/StreamChat/WebSocketClient/Events/EventPayload.swift @@ -243,7 +243,7 @@ extension EventPayload { /// Get an unwrapped value from the payload or throw an error. func value(at keyPath: KeyPath) throws -> Value { guard let value = self[keyPath: keyPath] else { - throw ClientError.EventDecoding(missingValue: keyPath.stringValue, for: eventType) + throw ClientError.EventDecoding(missingValue: keyPath.stringValue, for: eventType.rawValue) } return value @@ -252,11 +252,11 @@ extension EventPayload { /// Get the value from the event payload and if it is a `Result` report the decoding error. func value(at keyPath: KeyPath?>) throws -> Value { guard let value = self[keyPath: keyPath] else { - throw ClientError.EventDecoding(missingValue: keyPath.stringValue, for: eventType) + throw ClientError.EventDecoding(missingValue: keyPath.stringValue, for: eventType.rawValue) } if let error = value.error { - throw ClientError.EventDecoding(failedParsingValue: keyPath.stringValue, for: eventType, with: error) + throw ClientError.EventDecoding(failedParsingValue: keyPath.stringValue, for: eventType.rawValue, with: error) } return try value.get() diff --git a/Sources/StreamChat/WebSocketClient/RetryStrategy.swift b/Sources/StreamChat/WebSocketClient/RetryStrategy.swift deleted file mode 100644 index 3ee95c4ebfa..00000000000 --- a/Sources/StreamChat/WebSocketClient/RetryStrategy.swift +++ /dev/null @@ -1,65 +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 { - var delay: TimeInterval = 0 - - _consecutiveFailuresCount.mutate { - let maxDelay: TimeInterval = min(0.5 + Double($0 * 2), Self.maximumReconnectionDelay) - let minDelay: TimeInterval = min(max(0.25, (Double($0) - 1) * 2), Self.maximumReconnectionDelay) - - delay = TimeInterval.random(in: minDelay...maxDelay) - } - - return delay - } -} diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift deleted file mode 100644 index b6c15f2c79a..00000000000 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ /dev/null @@ -1,306 +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, .waitingForConnectionId, .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, .waitingForConnectionId, .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: Timer.Type, _ timerQueue: DispatchQueue) -> WebSocketPingController - - typealias CreateEngine = ( - _ request: URLRequest, - _ sessionConfiguration: URLSessionConfiguration, - _ callbackQueue: DispatchQueue - ) -> WebSocketEngine - - var timerType: Timer.Type = DefaultTimer.self - - var createPingController: CreatePingController = WebSocketPingController.init - - var createEngine: CreateEngine = { - URLSessionWebSocketEngine(request: $0, sessionConfiguration: $1, callbackQueue: $2) - } - - var eventBatcherBuilder: ( - _ handler: @escaping ([Event], @escaping @Sendable () -> Void) -> Void - ) -> EventBatcher = { - Batcher(period: 0.5, handler: $0) - } - } -} - -// MARK: - Web Socket Delegate - -extension WebSocketClient: WebSocketEngineDelegate { - func webSocketDidConnect() { - connectionState = .waitingForConnectionId - } - - 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(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, .waitingForConnectionId, .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 - -extension ClientError { - public final class WebSocket: ClientError, @unchecked Sendable {} -} - -/// 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 6a4dd11fe74..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: Timer.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: Timer.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 7b03f557d32..00000000000 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ /dev/null @@ -1,279 +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: Timer.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: Timer.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, .waitingForConnectionId, .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, .waitingForConnectionId, .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..3a6277ae018 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 EventPersistentNotificationCenter: NotificationCenter, EventNotificationCenter, @unchecked Sendable { private(set) var middlewares: [EventMiddleware] = [] /// The database used when evaluating middlewares. @@ -97,36 +97,30 @@ class EventNotificationCenter: NotificationCenter, @unchecked Sendable { } } -extension EventNotificationCenter { +extension EventPersistentNotificationCenter { 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) +extension Notification.Name { + /// The name of the notification posted when a new event is published/ + static let NewEventReceived = Notification.Name("io.getstream.new_event_received") +} + +extension Notification { + private static let eventKey = "io.getstream.event_key" + + init(newEventReceived event: Event, sender: Any) { + self.init(name: .NewEventReceived, object: sender, userInfo: [Self.eventKey: event]) } - 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) + var event: Event? { + userInfo?[Self.eventKey] as? Event } +} +extension EventPersistentNotificationCenter { static func channelFilter(cid: ChannelId, event: Event) -> Bool { switch event { case let channelEvent as ChannelSpecificEvent: diff --git a/Sources/StreamChat/Workers/TypingEventsSender.swift b/Sources/StreamChat/Workers/TypingEventsSender.swift index 55b24f670d4..66035ee4b7c 100644 --- a/Sources/StreamChat/Workers/TypingEventsSender.swift +++ b/Sources/StreamChat/Workers/TypingEventsSender.swift @@ -22,7 +22,7 @@ private struct TypingInfo { /// Sends typing events. class TypingEventsSender: Worker, @unchecked Sendable { /// A timer type. - var timer: Timer.Type = DefaultTimer.self + var timer: TimerScheduling.Type = DefaultTimer.self /// `TypingInfo` for channel (and parent message) that typing has occurred in. Stored to stop typing when `TypingEventsSender` is deallocated @Atomic private var typingInfo: TypingInfo? diff --git a/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift b/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift new file mode 100644 index 00000000000..d9e7ebdce03 --- /dev/null +++ b/Sources/StreamChatUI/Utils/Extensions/StreamCore+Extensions.swift @@ -0,0 +1,6 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamChat +@_exported import StreamCore diff --git a/Sources/StreamChatUI/Utils/StreamConcurrency.swift b/Sources/StreamChatUI/Utils/StreamConcurrency.swift deleted file mode 100644 index 3c83dd990bb..00000000000 --- a/Sources/StreamChatUI/Utils/StreamConcurrency.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation - -enum StreamConcurrency { - /// Synchronously performs the provided action on the main thread. - /// - /// Used for ensuring that we are on the main thread when compiler can't know it. For example, - /// controller completion handlers by default are called from main thread, but one can - /// configure controller to use background thread for completions instead. - /// - /// - Important: It is safe to call from any thread. It does not deadlock if we are already on the main thread. - /// - Important: Prefer Task { @MainActor if possible. - static func onMain(_ action: @MainActor () throws -> T) rethrows -> T where T: Sendable { - if Thread.current.isMainThread { - return try MainActor.assumeIsolated { - try action() - } - } else { - // We use sync here, because this function supports returning a value. - return try DispatchQueue.main.sync { - try action() - } - } - } -} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 64866157cb3..68b34737d96 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -287,8 +287,6 @@ 4F73F3992B91BD3000563CD9 /* MessageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73F3972B91BD3000563CD9 /* MessageState.swift */; }; 4F73F39E2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73F39D2B91C7BF00563CD9 /* MessageState+Observer.swift */; }; 4F73F39F2B91C7BF00563CD9 /* MessageState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73F39D2B91C7BF00563CD9 /* MessageState+Observer.swift */; }; - 4F7B58952DCB6B5A0034CC0F /* AllocatedUnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7B58942DCB6B5A0034CC0F /* AllocatedUnfairLock.swift */; }; - 4F7B58962DCB6B5A0034CC0F /* AllocatedUnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7B58942DCB6B5A0034CC0F /* AllocatedUnfairLock.swift */; }; 4F83FA462BA43DC3008BD8CD /* MemberList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F83FA452BA43DC3008BD8CD /* MemberList.swift */; }; 4F83FA472BA43DC3008BD8CD /* MemberList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F83FA452BA43DC3008BD8CD /* MemberList.swift */; }; 4F862F9A2C38001000062502 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F862F992C38001000062502 /* FileManager+Extensions.swift */; }; @@ -324,8 +322,10 @@ 4F97F2782BA87E30001C4D66 /* MessageSearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2762BA87E30001C4D66 /* MessageSearchState.swift */; }; 4F97F27A2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; }; 4F97F27B2BA88936001C4D66 /* MessageSearchState+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */; }; - 4F9E5EE42D8C439700047754 /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */; }; - 4F9E5EE52D8C439700047754 /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */; }; + 4FAF7A572EAA1D5E006F74A1 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAF7A562EAA1D58006F74A1 /* StreamCore+Extensions.swift */; }; + 4FAF7A582EAA1D5E006F74A1 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAF7A562EAA1D58006F74A1 /* StreamCore+Extensions.swift */; }; + 4FAF7A5A2EAA1DF4006F74A1 /* StreamCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FAF7A592EAA1DF0006F74A1 /* StreamCore+Extensions.swift */; }; + 4FAF7D132EAB6B8B006F74A1 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4FAF7D122EAB6B8B006F74A1 /* StreamCore */; }; 4FB4AB9F2BAD6DBD00712C4E /* Chat_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */; }; 4FBD840B2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */; }; 4FC743EC2D8D9A2600E314EB /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC743B22D8D9A2600E314EB /* ImageDecoding.swift */; }; @@ -527,14 +527,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 */; }; - 79280F712487CD2B00CDEB89 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F6F2487CD2B00CDEB89 /* Atomic.swift */; }; - 79280F732487CD3100CDEB89 /* Atomic_StressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F702487CD2B00CDEB89 /* Atomic_StressTests.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 */; }; - 792A4F3C247FFBB400EAF71D /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F3B247FFBB400EAF71D /* Timers.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 */; }; @@ -613,8 +608,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 */; }; - 7985BDAA252B1E53002B8C30 /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7985BDA9252B1E53002B8C30 /* StreamConcurrency.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 */; }; @@ -636,14 +629,12 @@ 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 */; }; 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 */; }; @@ -655,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 */; }; @@ -670,7 +660,6 @@ 79BA19F324B3386B00E11FC2 /* CurrentUserDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BA19F224B3386B00E11FC2 /* CurrentUserDTO_Tests.swift */; }; 79C5CBE825F66DBD00D98001 /* ChatChannelWatcherListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5CBE725F66DBD00D98001 /* ChatChannelWatcherListController.swift */; }; 79C5CBF125F66E9700D98001 /* ChannelWatcherListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C5CBF025F66E9700D98001 /* ChannelWatcherListQuery.swift */; }; - 79C750BB248FC4100023F0B7 /* ErrorPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C750BA248FC4100023F0B7 /* ErrorPayload.swift */; }; 79CCB66E259CBC4F0082F172 /* ChatChannelNamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79CCB66D259CBC4F0082F172 /* ChatChannelNamer.swift */; }; 79CD959224F9380B00E87377 /* MulticastDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79CD959124F9380B00E87377 /* MulticastDelegate.swift */; }; 79CD959424F9381700E87377 /* MulticastDelegate_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79CD959324F9381700E87377 /* MulticastDelegate_Tests.swift */; }; @@ -904,7 +893,6 @@ 84A43CB326A9A54700302763 /* EventSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A43CB226A9A54700302763 /* EventSender.swift */; }; 84AA4E3626F264610056A684 /* EventDTOConverterMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA4E3526F264610056A684 /* EventDTOConverterMiddleware.swift */; }; 84ABB015269F0A84003A4585 /* EventsController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ABB014269F0A84003A4585 /* EventsController+Combine.swift */; }; - 84ABF69A274E570600EDDA68 /* EventBatcher_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ABF699274E570600EDDA68 /* EventBatcher_Tests.swift */; }; 84AC14B52BC34B4F009D1245 /* ChannelList_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AC14B42BC34B4F009D1245 /* ChannelList_Mock.swift */; }; 84AD17DC28F853B0008C69BF /* DemoAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648EC575261EF9D400B8F05F /* DemoAppCoordinator.swift */; }; 84AD17DD28F85701008C69BF /* PushNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3227EC8284A52EE00EBE6CC /* PushNotifications.swift */; }; @@ -931,7 +919,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 */; }; @@ -947,7 +934,6 @@ 84E46A392CFA1B8E000CBDDE /* AIIndicatorUpdate.json in Resources */ = {isa = PBXBuildFile; fileRef = 84E46A362CFA1B8E000CBDDE /* AIIndicatorUpdate.json */; }; 84E46A3B2CFA1BB9000CBDDE /* AIIndicatorEvents_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E46A3A2CFA1BB9000CBDDE /* AIIndicatorEvents_Tests.swift */; }; 84EB4E76276A012900E47E73 /* ClientError_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB4E75276A012900E47E73 /* ClientError_Tests.swift */; }; - 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EB4E77276A03DE00E47E73 /* ErrorPayload_Tests.swift */; }; 84EE53B12BBC32AD00FD2A13 /* Chat_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EE53B02BBC32AD00FD2A13 /* Chat_Mock.swift */; }; 84EE53B52BBDAC1D00FD2A13 /* UserSearch_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84EE53B42BBDAC1D00FD2A13 /* UserSearch_Mock.swift */; }; 84F373EC280D803E0081E8BA /* TestChannelObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F373EB280D803E0081E8BA /* TestChannelObserver.swift */; }; @@ -1073,7 +1059,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 */; }; @@ -1090,7 +1075,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 */; }; @@ -1101,8 +1085,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 */; }; @@ -1292,8 +1274,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 */; }; A3A0C9A1283E955200B18DA4 /* ChannelResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824445AA27EA364300DB2FD8 /* ChannelResponses.swift */; }; @@ -1446,7 +1426,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 */; }; @@ -1723,7 +1702,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 */; }; @@ -2113,14 +2091,7 @@ 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 */; }; - 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 */; }; C121E82C274544AD00023E4C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 799C9442247D3DA7001F1104 /* APIClient.swift */; }; C121E82D274544AD00023E4C /* CDNClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649968D3264E660B000515AB /* CDNClient.swift */; }; @@ -2181,7 +2152,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 */; }; @@ -2283,16 +2253,12 @@ C121E8D0274544B100023E4C /* UserListSortingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA640FBD2535CF9200D32944 /* UserListSortingKey.swift */; }; C121E8D1274544B100023E4C /* ChannelMemberListSortingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA640FC02535CFA100D32944 /* ChannelMemberListSortingKey.swift */; }; C121E8D2274544B100023E4C /* ClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F3E2484E3BA00CDEB89 /* ClientError.swift */; }; - C121E8D3274544B100023E4C /* ErrorPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C750BA248FC4100023F0B7 /* ErrorPayload.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 */; }; - C121E8E0274544B100023E4C /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79280F6F2487CD2B00CDEB89 /* Atomic.swift */; }; C121E8E1274544B100023E4C /* OptionalDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DD8EC025E4083B0059A322 /* OptionalDecodable.swift */; }; C121E8E2274544B200023E4C /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F3D247FFDE700EAF71D /* Codable+Extensions.swift */; }; - C121E8E5274544B200023E4C /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792A4F3B247FFBB400EAF71D /* Timers.swift */; }; C121E8E6274544B200023E4C /* SystemEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756524814EF8003CF16D /* SystemEnvironment.swift */; }; C121E8E7274544B200023E4C /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797A756724814F0D003CF16D /* Bundle+Extensions.swift */; }; C121E8E8274544B200023E4C /* OptionSet+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792FCB4824A3BF38000290C7 /* OptionSet+Extensions.swift */; }; @@ -2300,7 +2266,6 @@ C121E8EC274544B200023E4C /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4AA3B92502731900FAAF6E /* Publisher+Extensions.swift */; }; C121E8ED274544B200023E4C /* UniqueId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797E10A724EAF6DE00353791 /* UniqueId.swift */; }; C121E8EE274544B200023E4C /* MulticastDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79CD959124F9380B00E87377 /* MulticastDelegate.swift */; }; - C121E8EF274544B200023E4C /* StreamConcurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7985BDA9252B1E53002B8C30 /* StreamConcurrency.swift */; }; C121E8F0274544B200023E4C /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EA9AD725470F6A007EE76B /* Dictionary+Extensions.swift */; }; C121E8F1274544B200023E4C /* MultipartFormData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881506EB258212BF0013935B /* MultipartFormData.swift */; }; C121E8F2274544B200023E4C /* SystemEnvironment+Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AD954E25D536AA00076DC3 /* SystemEnvironment+Version.swift */; }; @@ -2670,7 +2635,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 */; }; @@ -2679,7 +2643,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 */; }; @@ -2745,7 +2708,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 */; }; @@ -3352,7 +3314,6 @@ 4F6B840F2D008D5F005645B0 /* MemberUpdatePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberUpdatePayload.swift; sourceTree = ""; }; 4F73F3972B91BD3000563CD9 /* MessageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageState.swift; sourceTree = ""; }; 4F73F39D2B91C7BF00563CD9 /* MessageState+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageState+Observer.swift"; sourceTree = ""; }; - 4F7B58942DCB6B5A0034CC0F /* AllocatedUnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllocatedUnfairLock.swift; sourceTree = ""; }; 4F83FA452BA43DC3008BD8CD /* MemberList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberList.swift; sourceTree = ""; }; 4F862F992C38001000062502 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; 4F877D392D019E0400CB66EC /* ChannelPinningScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPinningScope.swift; sourceTree = ""; }; @@ -3371,7 +3332,8 @@ 4F97F2732BA87C41001C4D66 /* MessageSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearch.swift; sourceTree = ""; }; 4F97F2762BA87E30001C4D66 /* MessageSearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSearchState.swift; sourceTree = ""; }; 4F97F2792BA88936001C4D66 /* MessageSearchState+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSearchState+Observer.swift"; sourceTree = ""; }; - 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamConcurrency.swift; sourceTree = ""; }; + 4FAF7A562EAA1D58006F74A1 /* StreamCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamCore+Extensions.swift"; sourceTree = ""; }; + 4FAF7A592EAA1DF0006F74A1 /* StreamCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamCore+Extensions.swift"; sourceTree = ""; }; 4FB4AB9E2BAD6DBD00712C4E /* Chat_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat_Tests.swift; sourceTree = ""; }; 4FBD840A2C774E5C00B1E680 /* AttachmentDownloader_Spy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloader_Spy.swift; sourceTree = ""; }; 4FC743A82D8D9A2600E314EB /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; @@ -3506,17 +3468,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 = ""; }; - 79280F6F2487CD2B00CDEB89 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - 79280F702487CD2B00CDEB89 /* Atomic_StressTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic_StressTests.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 = ""; }; - 792A4F3B247FFBB400EAF71D /* Timers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Timers.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 = ""; }; @@ -3607,8 +3564,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 = ""; }; - 7985BDA9252B1E53002B8C30 /* StreamConcurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamConcurrency.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 = ""; }; @@ -3637,7 +3592,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 = ""; }; @@ -3647,7 +3601,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 = ""; }; @@ -3658,7 +3611,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 = ""; }; @@ -3675,8 +3627,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 = ""; }; - 79C750BA248FC4100023F0B7 /* ErrorPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPayload.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 = ""; }; @@ -3885,7 +3835,6 @@ 84A43CB226A9A54700302763 /* EventSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSender.swift; sourceTree = ""; }; 84AA4E3526F264610056A684 /* EventDTOConverterMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDTOConverterMiddleware.swift; sourceTree = ""; }; 84ABB014269F0A84003A4585 /* EventsController+Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EventsController+Combine.swift"; sourceTree = ""; }; - 84ABF699274E570600EDDA68 /* EventBatcher_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBatcher_Tests.swift; sourceTree = ""; }; 84ABF69B274E66AA00EDDA68 /* EventBatcher_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBatcher_Mock.swift; sourceTree = ""; }; 84AC14B42BC34B4F009D1245 /* ChannelList_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelList_Mock.swift; sourceTree = ""; }; 84B738392BE8BF8E00EC66EC /* PollController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollController+Combine.swift"; sourceTree = ""; }; @@ -3904,7 +3853,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 = ""; }; @@ -3921,7 +3869,6 @@ 84E46A3A2CFA1BB9000CBDDE /* AIIndicatorEvents_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIIndicatorEvents_Tests.swift; sourceTree = ""; }; 84EB4E732769F76500E47E73 /* BackgroundTaskScheduler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskScheduler_Mock.swift; sourceTree = ""; }; 84EB4E75276A012900E47E73 /* ClientError_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError_Tests.swift; sourceTree = ""; }; - 84EB4E77276A03DE00E47E73 /* ErrorPayload_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPayload_Tests.swift; sourceTree = ""; }; 84EE53B02BBC32AD00FD2A13 /* Chat_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat_Mock.swift; sourceTree = ""; }; 84EE53B42BBDAC1D00FD2A13 /* UserSearch_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSearch_Mock.swift; sourceTree = ""; }; 84F373EB280D803E0081E8BA /* TestChannelObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestChannelObserver.swift; sourceTree = ""; }; @@ -4068,7 +4015,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 = ""; }; @@ -4107,7 +4053,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 = ""; }; @@ -4126,9 +4071,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 = ""; }; @@ -4216,8 +4159,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 = ""; }; A39B040A27F196F200D6B18A /* StreamChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamChatUITests.swift; sourceTree = ""; }; @@ -4917,7 +4858,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 = ""; }; @@ -4926,7 +4866,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 = ""; }; @@ -5007,7 +4946,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 = ""; }; @@ -5192,6 +5130,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4FAF7D132EAB6B8B006F74A1 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5832,7 +5771,6 @@ A364D0A327D126490029857A /* Repositories */, 4FB4AB9D2BAD6D9700712C4E /* StateLayer */, A34ECB4427F5C9AD00A804C1 /* StreamChatIntegrationTests */, - A34ECB4327F5C93C00A804C1 /* StreamChatStressTests */, 40FC028E29BE9B0600E2A1CD /* Audio */, A364D0BB27D12AD50029857A /* Utils */, A364D08C27D0BD7B0029857A /* WebSocketClient */, @@ -5892,7 +5830,6 @@ isa = PBXGroup; children = ( 79280F3E2484E3BA00CDEB89 /* ClientError.swift */, - 79C750BA248FC4100023F0B7 /* ErrorPayload.swift */, ); path = Errors; sourceTree = ""; @@ -5926,22 +5863,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 = ( @@ -5990,16 +5917,13 @@ ADE2093B29FBEC88007D0FF3 /* MessagesPaginationStateHandling */, C1E8AD5A278DDEBB0041B775 /* Operations */, CF324E762832FC1200E5BBE6 /* StreamTimer */, - 4F7B58942DCB6B5A0034CC0F /* AllocatedUnfairLock.swift */, 40D3962D2A0910DE0020DDC9 /* Array+Sampling.swift */, - 79280F6F2487CD2B00CDEB89 /* Atomic.swift */, 797A756724814F0D003CF16D /* Bundle+Extensions.swift */, 792A4F3D247FFDE700EAF71D /* Codable+Extensions.swift */, CF6E489E282341F2008416DC /* CountdownTracker.swift */, ADFCA5B82D1378E2000F515F /* Throttler.swift */, 40789D3B29F6AD9C0018C2BB /* Debouncer.swift */, 88EA9AD725470F6A007EE76B /* Dictionary+Extensions.swift */, - 84CF9C72274D473D00BCDE2D /* EventBatcher.swift */, C173538D27D9F804008AC412 /* KeyedDecodingContainer+Array.swift */, 4FE56B8C2D5DFE3A00589F9A /* MarkdownParser.swift */, 79CD959124F9380B00E87377 /* MulticastDelegate.swift */, @@ -6009,12 +5933,10 @@ DA4AA3B92502731900FAAF6E /* Publisher+Extensions.swift */, DAFAD6A024DC476A0043ED06 /* Result+Extensions.swift */, C14D27B52869EEE40063F6F2 /* Sequence+CompactMapLoggingError.swift */, - 7985BDA9252B1E53002B8C30 /* StreamConcurrency.swift */, 797A756524814EF8003CF16D /* SystemEnvironment.swift */, CFA41B6527DA724400427602 /* SystemEnvironment+XStreamClient.swift */, AD0F7F162B6139D500914C4C /* TextLinkDetector.swift */, C14A46522845043300EF498E /* ThreadSafeWeakCollection.swift */, - 792A4F3B247FFBB400EAF71D /* Timers.swift */, 79D5CDD027EA1BA100BE7D8B /* TranslationLanguage.swift */, 797E10A724EAF6DE00353791 /* UniqueId.swift */, 4F910C6B2BEE1BDC00214EB9 /* UnreadMessageLookup.swift */, @@ -6217,13 +6139,7 @@ 799C9426247D2FB9001F1104 /* WebSocketClient */ = { isa = PBXGroup; children = ( - DBF17AE725D48865004517B3 /* BackgroundTaskScheduler.swift */, - 79C750BD2490D0130023F0B7 /* ConnectionStatus.swift */, - 799C9444247D3DD2001F1104 /* WebSocketClient.swift */, 797A756324814E7A003CF16D /* WebSocketConnectPayload.swift */, - 8A618E4424D19D510003D83C /* WebSocketPingController.swift */, - 799BE2E9248A8C9D00DAC8A0 /* RetryStrategy.swift */, - 79280F76248917EB00CDEB89 /* Engine */, 796610B7248E64EC00761629 /* EventMiddlewares */, 79280F402484F4DD00CDEB89 /* Events */, ); @@ -6708,7 +6624,6 @@ ACF73D7726CFE07900372DC0 /* Cancellable.swift */, 88A11B092590AFBB0000AC24 /* ChatMessage+Extensions.swift */, ACA3C98526CA23F300EB8B07 /* DateUtils.swift */, - 4F9E5EE32D8C438900047754 /* StreamConcurrency.swift */, 79F691B12604C10A000AE89B /* SystemEnvironment.swift */, CF7B2A2528BEAA93006BE124 /* TextViewMentionedUsersHandler.swift */, AD4118822D5E135D000EF88E /* UILabel+highlightText.swift */, @@ -7025,7 +6940,6 @@ 8AE335A324FCF95D002B6677 /* InternetConnection */ = { isa = PBXGroup; children = ( - 8AE335A624FCF999002B6677 /* InternetConnection.swift */, 8AE335A524FCF999002B6677 /* Reachability_Vendor.swift */, 79200D4B25025B81002F4EB1 /* Error+InternetNotAvailable.swift */, ); @@ -7047,6 +6961,7 @@ A3227E75284A4C6400EBE6CC /* MessageReactionType+Position.swift */, A3227E58284A484300EBE6CC /* UIImage+Resized.swift */, A3227E5A284A489000EBE6CC /* UIViewController+Alert.swift */, + 4FAF7A592EAA1DF0006F74A1 /* StreamCore+Extensions.swift */, 849C1B69283686EE00F9DC42 /* UserDefaults+Shared.swift */, ); path = Extensions; @@ -7281,14 +7196,6 @@ path = StreamChat; sourceTree = ""; }; - A34ECB4327F5C93C00A804C1 /* StreamChatStressTests */ = { - isa = PBXGroup; - children = ( - 79280F702487CD2B00CDEB89 /* Atomic_StressTests.swift */, - ); - path = StreamChatStressTests; - sourceTree = ""; - }; A34ECB4427F5C9AD00A804C1 /* StreamChatIntegrationTests */ = { isa = PBXGroup; children = ( @@ -7332,12 +7239,7 @@ A364D08C27D0BD7B0029857A /* WebSocketClient */ = { isa = PBXGroup; 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 */, A364D08D27D0BD8E0029857A /* EventMiddlewares */, A364D08E27D0BDB20029857A /* Events */, ); @@ -7557,7 +7459,6 @@ children = ( ADCC179E2E09D96A00510415 /* ActiveLiveLocationsEndTimeTracker_Tests.swift */, 88F7692A25837EE600BD36B0 /* AttachmentQueueUploader_Tests.swift */, - A3960E0C27DA5973003AB2B0 /* ConnectionRecoveryHandler_Tests.swift */, F61D7C3624FFE17200188A0E /* MessageEditor_Tests.swift */, 791C0B6224EEBDF40013CA2F /* MessageSender_Tests.swift */, ); @@ -7926,7 +7827,6 @@ isa = PBXGroup; children = ( 84EB4E75276A012900E47E73 /* ClientError_Tests.swift */, - 84EB4E77276A03DE00E47E73 /* ErrorPayload_Tests.swift */, ); path = Errors; sourceTree = ""; @@ -7944,7 +7844,6 @@ CF1F1D442824243F002E2977 /* CooldownTracker_Tests.swift */, 40789D3E29F6AFC40018C2BB /* Debouncer_Tests.swift */, 88EA9AE125471999007EE76B /* Dictionary_Tests.swift */, - 84ABF699274E570600EDDA68 /* EventBatcher_Tests.swift */, BCE48068C1C02C0689BEB64E /* JSONDecoder_Tests.swift */, BCE489A4B136D48249DD6969 /* JSONEncoder_Tests.swift */, 79CD959324F9381700E87377 /* MulticastDelegate_Tests.swift */, @@ -7979,7 +7878,6 @@ A364D0BE27D12C950029857A /* InternetConnection */ = { isa = PBXGroup; children = ( - 8AE335A424FCF999002B6677 /* InternetConnection_Tests.swift */, 64F70D4A26257FD400C9F979 /* Error+InternetNotAvailable_Tests.swift */, ); path = InternetConnection; @@ -9145,6 +9043,7 @@ 4F05ECB72B6CCA4900641820 /* DifferenceKit+Stream.swift */, 88D88F85257F9AA700AFE2A2 /* NSLayoutConstraint+Extensions.swift */, E386432C2857299E00DB3FBE /* Reusable+Extensions.swift */, + 4FAF7A562EAA1D58006F74A1 /* StreamCore+Extensions.swift */, 2241167F258A91280034184D /* String+Extensions.swift */, 224FF6902562F58F00725DD1 /* UIColor+Extensions.swift */, 228190EA256733420048D7C6 /* UIFont+Extensions.swift */, @@ -10278,6 +10177,7 @@ ); name = StreamChatTestMockServer; packageProductDependencies = ( + 4FAF7D122EAB6B8B006F74A1 /* StreamCore */, ); productName = StreamChatUITestTools; productReference = A3A0C999283E952900B18DA4 /* StreamChatTestMockServer.framework */; @@ -11135,6 +11035,7 @@ 88CABC6625934CF60061BB67 /* ChatMessageReactions+Types.swift in Sources */, BD69F5D52669392E00E9E3FA /* ScrollToBottomButton.swift in Sources */, AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */, + 4FAF7A582EAA1D5E006F74A1 /* StreamCore+Extensions.swift in Sources */, 790882F725486B8000896F03 /* ChatChannelListCollectionViewDelegate.swift in Sources */, 43F4750C26F4E4FF0009487D /* ChatMessageReactionItemView.swift in Sources */, DBF12128258BAFC1001919C6 /* OnlyLinkTappableTextView.swift in Sources */, @@ -11201,7 +11102,6 @@ E7073A6325DD67B3003896B9 /* UILabel+Extensions.swift in Sources */, C171041E2768C34E008FB3F2 /* Array+SafeSubscript.swift in Sources */, ADD3285E2C05447200BAD0E9 /* ChatThreadListVC.swift in Sources */, - 4F9E5EE52D8C439700047754 /* StreamConcurrency.swift in Sources */, 843F0BC526775D2D00B342CB /* VideoLoading.swift in Sources */, 88CABC4625933EE70061BB67 /* ChatReactionPickerBubbleView.swift in Sources */, AD7EFDB32C78DBF600625FC5 /* PollCommentListSectionHeaderView.swift in Sources */, @@ -11464,6 +11364,7 @@ 7933064925712C8B00FBB586 /* DemoUsers.swift in Sources */, 792DDA5A256FB69E001DB91B /* AppDelegate.swift in Sources */, 792DDAA125711AF2001DB91B /* CreateChatViewController.swift in Sources */, + 4FAF7A5A2EAA1DF4006F74A1 /* StreamCore+Extensions.swift in Sources */, A3227E7E284A511200EBE6CC /* DemoAppConfiguration.swift in Sources */, AD7110C42B3434F700AFFE28 /* StreamRuntimeCheck+StreamInternal.swift in Sources */, AD6BEFF227862F9300E184B4 /* AppConfigViewController.swift in Sources */, @@ -11777,7 +11678,6 @@ 4F1BEE792BE384FE00B6685C /* ReactionListState.swift in Sources */, 79C5CBE825F66DBD00D98001 /* ChatChannelWatcherListController.swift in Sources */, 8A62706E24BF45360040BFD6 /* BanEnabling.swift in Sources */, - 7985BDAA252B1E53002B8C30 /* StreamConcurrency.swift in Sources */, ADF9E1F72A03E7E400109108 /* MessagesPaginationState.swift in Sources */, 79983C8126663436000995F6 /* ChatMessageVideoAttachment.swift in Sources */, ADEEB7F22BD1368900C76602 /* MessageReactionGroupPayload.swift in Sources */, @@ -11792,11 +11692,9 @@ 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 */, - 84CF9C73274D473D00BCDE2D /* EventBatcher.swift in Sources */, 40789D1B29F6AC500018C2BB /* AudioPlaybackState.swift in Sources */, C1E8AD57278C8A6E0041B775 /* SyncRepository.swift in Sources */, 84B7383D2BE8C13A00EC66EC /* PollController+SwiftUI.swift in Sources */, @@ -11815,7 +11713,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 */, @@ -11853,7 +11750,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 */, @@ -11870,7 +11766,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 */, @@ -11899,9 +11794,7 @@ 79C5CBF125F66E9700D98001 /* ChannelWatcherListQuery.swift in Sources */, 792A4F3F247FFDE700EAF71D /* Codable+Extensions.swift in Sources */, 8819DFCF2525F3C600FD1A50 /* UserUpdater.swift in Sources */, - 79C750BB248FC4100023F0B7 /* ErrorPayload.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 */, @@ -11924,7 +11817,6 @@ 88E26D7D2580F95300F55AB5 /* AttachmentEndpoints.swift in Sources */, F6D61D9B2510B3FC00EB0624 /* NSManagedObject+Extensions.swift in Sources */, DA4AA3B8250271BD00FAAF6E /* CurrentUserController+Combine.swift in Sources */, - 79280F712487CD2B00CDEB89 /* Atomic.swift in Sources */, AD7AC99B260A9572004AADA5 /* MessagePinning.swift in Sources */, AD4E87A22E39167C00223A1C /* LivestreamChannelController+Combine.swift in Sources */, ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */, @@ -11947,7 +11839,6 @@ 797A756824814F0D003CF16D /* Bundle+Extensions.swift in Sources */, 7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */, DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */, - 4F7B58952DCB6B5A0034CC0F /* AllocatedUnfairLock.swift in Sources */, 64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */, C1FFD9F927ECC7C7008A6848 /* Filter+predicate.swift in Sources */, 4F0757772E9FC0D500E5FD18 /* StreamCore+Extensions.swift in Sources */, @@ -11973,7 +11864,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 */, @@ -12034,8 +11924,6 @@ 88E26D5E2580E92000F55AB5 /* AttachmentId.swift in Sources */, AD94906F2BF68BB200E69224 /* ThreadListController+Combine.swift in Sources */, 799C9447247D50F3001F1104 /* Worker.swift in Sources */, - 79280F782489181200CDEB89 /* URLSessionWebSocketEngine.swift in Sources */, - 792A4F3C247FFBB400EAF71D /* Timers.swift in Sources */, 79682C4624BC9DAF0071578E /* ChannelUpdater.swift in Sources */, 8413D2EF2BDD9429005ADA4E /* PollVoteListController.swift in Sources */, 792A4F482480107A00EAF71D /* Pagination.swift in Sources */, @@ -12110,7 +11998,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 */, @@ -12136,7 +12023,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 */, @@ -12181,7 +12067,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 */, @@ -12210,7 +12095,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 */, @@ -12260,7 +12144,6 @@ 79D6CEA525F7D73700BE2EEC /* ChatChannelWatcherListController+Combine_Tests.swift in Sources */, 40789D4B29F6C87B0018C2BB /* AudioRecordingState_Tests.swift in Sources */, 4042967229FA97110089126D /* StreamΑudioRecorderMeterNormaliser_Tests.swift in Sources */, - 84ABF69A274E570600EDDA68 /* EventBatcher_Tests.swift in Sources */, DA4971542549C2A000AC68C2 /* MessageAttachmentPayload_Tests.swift in Sources */, 84D5BC59277B188E00A65C75 /* PinnedMessagesPagination_Tests.swift in Sources */, A36C39F828606B5D0004EB7E /* URL_EnrichedURL_Tests.swift in Sources */, @@ -12315,13 +12198,11 @@ 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 */, 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 */, @@ -12338,7 +12219,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 */, @@ -12366,14 +12246,12 @@ 8A0D64A924E57A560017A3C0 /* GuestUserTokenRequestPayload_Tests.swift in Sources */, AD7977BA2936D9450008B5FB /* Token_Tests.swift in Sources */, 4042967429FAB6EE0089126D /* StreamAudioSessionConfigurator_Tests.swift in Sources */, - 79280F732487CD3100CDEB89 /* Atomic_StressTests.swift in Sources */, 7964F3BE249A5E6E002A09EC /* RequestEncoder_Tests.swift in Sources */, 79DDF810249CB92E002F4412 /* RequestDecoder_Tests.swift in Sources */, A3F65E3A27EB72F6003F6256 /* Event+Equatable.swift in Sources */, 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 */, @@ -12416,7 +12294,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 */, @@ -12460,7 +12337,6 @@ 8AC9CBE424C74ECB006E236C /* NotificationEvents_Tests.swift in Sources */, 405D172D2A03E57C00A77C3B /* AVAssetTotalAudioSamples_Tests.swift in Sources */, ADE57B892C3C626100DD6B88 /* ThreadEvents_Tests.swift in Sources */, - 84EB4E78276A03DE00E47E73 /* ErrorPayload_Tests.swift in Sources */, DA4EE5B5252B680700CB26D4 /* UserListController+SwiftUI_Tests.swift in Sources */, 8A0D649824E579AB0017A3C0 /* GuestEndpoints_Tests.swift in Sources */, AD7C76832E42C0B5009250FB /* LivestreamChannelController+Combine_Tests.swift in Sources */, @@ -12727,24 +12603,16 @@ 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 */, - C121E825274544AD00023E4C /* BackgroundTaskScheduler.swift in Sources */, - 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 */, ADA83B402D974DCC003B3928 /* MessageReminderListController.swift in Sources */, ADA83B412D974DCC003B3928 /* MessageReminderListController+Combine.swift in Sources */, C121E82B274544AD00023E4C /* APIPathConvertible.swift in Sources */, AD8FEE5C2AA8E1E400273F88 /* ChatClientFactory.swift in Sources */, - 4F7B58962DCB6B5A0034CC0F /* AllocatedUnfairLock.swift in Sources */, 40789D2E29F6AC500018C2BB /* AudioRecordingState.swift in Sources */, C14D27B72869EEE40063F6F2 /* Sequence+CompactMapLoggingError.swift in Sources */, C121E82C274544AD00023E4C /* APIClient.swift in Sources */, @@ -12850,7 +12718,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 */, @@ -13064,11 +12931,9 @@ 4F45802F2BEE0B4B0099F540 /* ChannelListLinker.swift in Sources */, C143789127BC03EE00E23965 /* EndpointPath.swift in Sources */, 841BAA582BD29DA5000C73E4 /* PollVote.swift in Sources */, - C121E8D3274544B100023E4C /* ErrorPayload.swift in Sources */, 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 */, @@ -13077,7 +12942,6 @@ 84B7383B2BE8BF8E00EC66EC /* PollController+Combine.swift in Sources */, CFE616BA28348A6100AE2ABF /* CountdownTracker.swift in Sources */, C121E8DF274544B100023E4C /* StringInterpolation+Extensions.swift in Sources */, - C121E8E0274544B100023E4C /* Atomic.swift in Sources */, ADB951A2291BD7CC00800554 /* UploadedAttachment.swift in Sources */, C121E8E1274544B100023E4C /* OptionalDecodable.swift in Sources */, C1CEF90A2A1CDF7600414931 /* UserUpdateMiddleware.swift in Sources */, @@ -13087,7 +12951,6 @@ AD7A11CC2DEE091400B8F963 /* LocationEndpoints.swift in Sources */, AD6E32A22BBC50110073831B /* ThreadListQuery.swift in Sources */, ADF0473A2DE4DAE4001C23D2 /* LocationPayloads.swift in Sources */, - C121E8E5274544B200023E4C /* Timers.swift in Sources */, C121E8E6274544B200023E4C /* SystemEnvironment.swift in Sources */, 4F97F2712BA86491001C4D66 /* UserSearchState.swift in Sources */, C121E8E7274544B200023E4C /* Bundle+Extensions.swift in Sources */, @@ -13100,12 +12963,10 @@ C121E8EC274544B200023E4C /* Publisher+Extensions.swift in Sources */, C121E8ED274544B200023E4C /* UniqueId.swift in Sources */, C121E8EE274544B200023E4C /* MulticastDelegate.swift in Sources */, - C121E8EF274544B200023E4C /* StreamConcurrency.swift in Sources */, C121E8F0274544B200023E4C /* Dictionary+Extensions.swift in Sources */, 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 */, @@ -13363,7 +13224,6 @@ C121EBD02746A1EA00023E4C /* ChatThreadVC.swift in Sources */, C121EBD12746A1EA00023E4C /* ChatThreadVC+SwiftUI.swift in Sources */, C121EBD22746A1EA00023E4C /* ChatThreadHeaderView.swift in Sources */, - 4F9E5EE42D8C439700047754 /* StreamConcurrency.swift in Sources */, C121EBD32746A1EA00023E4C /* ChatMessageReactionAuthorsVC.swift in Sources */, ADD2A99B28FF4F4B00A83305 /* StreamCDN.swift in Sources */, ADCB578A28A42D7700B81AE8 /* Changeset.swift in Sources */, @@ -13427,6 +13287,7 @@ C121EBF12746A1EB00023E4C /* ChatMessageContentView.swift in Sources */, AD4F89EA2C6B89B3006DF7E5 /* PollTimestampFormatter.swift in Sources */, AD76CE352A5F1133003CA182 /* ChatChannelSearchVC.swift in Sources */, + 4FAF7A572EAA1D5E006F74A1 /* StreamCore+Extensions.swift in Sources */, C121EBF22746A1EB00023E4C /* ChatMessageContentView+SwiftUI.swift in Sources */, 406776502A14CB550079B05C /* MediaButton.swift in Sources */, AD4F89D52C666471006DF7E5 /* PollResultsVC.swift in Sources */, @@ -15872,8 +15733,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GetStream/stream-core-swift"; requirement = { - kind = exactVersion; - version = 0.4.1; + branch = "chat-web-socket-migration"; + kind = branch; }; }; A3BD4869281FD4500090D511 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = { @@ -15951,6 +15812,11 @@ isa = XCSwiftPackageProductDependency; productName = StreamCore; }; + 4FAF7D122EAB6B8B006F74A1 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; 4FE28A882EA9115A0035B93E /* StreamCore */ = { isa = XCSwiftPackageProductDependency; package = 4FECE08E2E9392A3007D14F0 /* XCRemoteSwiftPackageReference "stream-core-swift" */; 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/StreamChatTestMockServer/Utilities/TestData.swift b/TestTools/StreamChatTestMockServer/Utilities/TestData.swift index f43dc4a1d00..f488ad7db1e 100644 --- a/TestTools/StreamChatTestMockServer/Utilities/TestData.swift +++ b/TestTools/StreamChatTestMockServer/Utilities/TestData.swift @@ -3,6 +3,7 @@ // @testable import StreamChat +@testable import StreamCore import XCTest public enum TestData { diff --git a/TestTools/StreamChatTestTools/Extensions/String+Date.swift b/TestTools/StreamChatTestTools/Extensions/String+Date.swift index cd1ed105ac1..571dcb09f2a 100644 --- a/TestTools/StreamChatTestTools/Extensions/String+Date.swift +++ b/TestTools/StreamChatTestTools/Extensions/String+Date.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamChat +@testable import StreamCore public extension String { /// Converts a string to `Date`. Only for testing! diff --git a/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift b/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift index d8877e2dea7..4976a7704bf 100644 --- a/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift +++ b/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift @@ -5,7 +5,7 @@ import Foundation @testable import StreamChat -class FakeTimer: StreamChat.Timer { +class FakeTimer: TimerScheduling { static let mockTimer = AllocatedUnfairLock(nil) static let mockRepeatingTimer = AllocatedUnfairLock(nil) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 9735d484c9b..9ca362b103a 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: EventPersistentNotificationCenter { 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: { @@ -283,7 +283,7 @@ extension ChatClient { ) return } - webSocketClient(mockWebSocketClient, didUpdateConnectionState: .connected(connectionId: connectionId)) + webSocketClient(mockWebSocketClient, didUpdateConnectionState: .connected(healthCheckInfo: HealthCheckInfo(connectionId: connectionId))) } } @@ -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) + EventPersistentNotificationCenter(database: $0, manualEventHandler: $1) }, authenticationRepositoryBuilder: { AuthenticationRepository_Mock( @@ -387,13 +386,13 @@ extension ChatClient.Environment { webSocketEnvironment.eventBatcherBuilder = { Batcher(period: 0, handler: $0) } - 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 26923b29ac8..1ccef774968 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(), + webSocketRequestEncoder: 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, + webSocketRequestEncoder: client.webSocketRequestEncoder, webSocketClient: client.webSocketClient, apiClient: client.apiClient, timerType: DefaultTimer.self ) } - override init(isClientInActiveMode: Bool, syncRepository: SyncRepository, webSocketClient: WebSocketClient?, apiClient: APIClient, timerType: StreamChat.Timer.Type) { + override init(isClientInActiveMode: Bool, syncRepository: SyncRepository, webSocketRequestEncoder: RequestEncoder?, webSocketClient: WebSocketClient?, apiClient: APIClient, timerType: TimerScheduling.Type) { super.init( isClientInActiveMode: isClientInActiveMode, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: timerType 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/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift index 4262d065e12..d2bf555f224 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift @@ -43,7 +43,7 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy, @unchecked S databaseContainer: DatabaseContainer, connectionRepository: ConnectionRepository, tokenExpirationRetryStrategy: RetryStrategy = DefaultRetryStrategy(), - timerType: StreamChat.Timer.Type = DefaultTimer.self + timerType: TimerScheduling.Type = DefaultTimer.self ) { super.init( apiClient: apiClient, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Utils/EventBatcher_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Utils/EventBatcher_Mock.swift index 533112cd452..c1c2515d70a 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Utils/EventBatcher_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Utils/EventBatcher_Mock.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamChat +@testable import StreamCore final class EventBatcher_Mock: EventBatcher, @unchecked Sendable { var currentBatch: [Event] = [] @@ -12,7 +13,7 @@ final class EventBatcher_Mock: EventBatcher, @unchecked Sendable { init( period: TimeInterval = 0, - timerType: StreamChat.Timer.Type = DefaultTimer.self, + timerType: TimerScheduling.Type = DefaultTimer.self, handler: @escaping (_ batch: [Event], _ completion: @escaping @Sendable () -> Void) -> Void ) { self.handler = handler diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/BackgroundTaskScheduler_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/BackgroundTaskScheduler_Mock.swift index f47b2acc4cf..6cff487a0f7 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/BackgroundTaskScheduler_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/BackgroundTaskScheduler_Mock.swift @@ -15,9 +15,9 @@ final class BackgroundTaskScheduler_Mock: BackgroundTaskScheduler, @unchecked Se } var beginBackgroundTask_called: Bool = false - var beginBackgroundTask_expirationHandler: (@MainActor () -> Void)? + var beginBackgroundTask_expirationHandler: (@Sendable () -> Void)? var beginBackgroundTask_returns: Bool = true - func beginTask(expirationHandler: (@MainActor () -> Void)?) -> Bool { + func beginTask(expirationHandler: (@Sendable () -> Void)?) -> Bool { beginBackgroundTask_called = true beginBackgroundTask_expirationHandler = expirationHandler return beginBackgroundTask_returns diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift index 3954a169e65..ed8b7d23bde 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift @@ -4,11 +4,11 @@ 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_environment: WebSocketClient.Environment @@ -21,15 +21,13 @@ 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 { super.connectionState } + set { super.connectionState = newValue } } init( sessionConfiguration: URLSessionConfiguration = .ephemeral, - requestEncoder: RequestEncoder = DefaultRequestEncoder(baseURL: .unique(), apiKey: .init(.unique)), eventDecoder: AnyEventDecoder = EventDecoder(), eventNotificationCenter: EventNotificationCenter = EventNotificationCenter_Mock(database: DatabaseContainer_Spy()), pingController: WebSocketPingController? = nil, @@ -38,7 +36,7 @@ final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { ) { 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 ) } @@ -69,6 +67,7 @@ final class WebSocketClient_Mock: WebSocketClient, @unchecked Sendable { } override func disconnect( + code: URLSessionWebSocketTask.CloseCode = .normalClosure, source: WebSocketConnectionState.DisconnectionSource = .userInitiated, completion: @escaping () -> Void ) { diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift index f71556cd900..a11aa3b9ea9 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketEngine_Mock.swift @@ -38,10 +38,18 @@ final class WebSocketEngine_Mock: WebSocketEngine, @unchecked Sendable { func disconnect() { disconnect_calledCount += 1 } + + func disconnect(with code: URLSessionWebSocketTask.CloseCode) { + disconnect_calledCount += 1 + } func sendPing() { sendPing_calledCount += 1 } + + func send(message: any StreamCore.SendableEvent) {} + + func send(jsonMessage: any Codable) {} // MARK: - Functions to simulate behavior @@ -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..49a6bacad21 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift @@ -6,7 +6,7 @@ import XCTest /// Mock implementation of `ConnectionRecoveryHandler` -final class ConnectionRecoveryHandler_Mock: ConnectionRecoveryHandler { +final class ConnectionRecoveryHandler_Mock: ConnectionRecoveryHandler, @unchecked Sendable { var startCallCount = 0 var stopCallCount = 0 diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/EventNotificationCenter_Mock.swift index d2149d4beb9..48b994b735f 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: EventPersistentNotificationCenter, @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 index c0dd916c15f..5e8e758d26b 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/QueueAware/WebSocketPingController_Delegate.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamChat +@testable import StreamCore // A concrete `WebSocketPingControllerDelegate` implementation allowing capturing the delegate calls final class WebSocketPingController_Delegate: WebSocketPingControllerDelegate { @@ -14,6 +15,10 @@ final class WebSocketPingController_Delegate: WebSocketPingControllerDelegate { sendPing_calledCount += 1 } + func sendPing(healthCheckEvent: any SendableEvent) { + sendPing_calledCount += 1 + } + func disconnectOnNoPongReceived() { disconnectOnNoPongReceived_calledCount += 1 } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift index dcbd35c0635..8f3d3e0c826 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/DatabaseContainer_Spy.swift @@ -137,7 +137,7 @@ public final class DatabaseContainer_Spy: DatabaseContainer, Spy, @unchecked Sen let completion: @Sendable (Error?) -> Void = { error in completion(error) - self._writeSessionCounter { $0 += 1 } + self._writeSessionCounter { $0 + 1 } self.didWrite?() } diff --git a/TestTools/StreamChatTestTools/SpyPattern/Spy/Logger_Spy.swift b/TestTools/StreamChatTestTools/SpyPattern/Spy/Logger_Spy.swift index b95a6ffaaa5..5c1d7897835 100644 --- a/TestTools/StreamChatTestTools/SpyPattern/Spy/Logger_Spy.swift +++ b/TestTools/StreamChatTestTools/SpyPattern/Spy/Logger_Spy.swift @@ -9,7 +9,7 @@ import StreamCore final class Logger_Spy: Logger, Spy, @unchecked Sendable { let spyState = SpyState() var originalLogger: Logger? - @StreamCore.Atomic var failedAsserts: Int = 0 + @Atomic var failedAsserts: Int = 0 func injectMock() { let logger = LogConfig.logger diff --git a/TestTools/StreamChatTestTools/VirtualTime/VirtualTimer.swift b/TestTools/StreamChatTestTools/VirtualTime/VirtualTimer.swift index 090a1dd204e..29a46523d8e 100644 --- a/TestTools/StreamChatTestTools/VirtualTime/VirtualTimer.swift +++ b/TestTools/StreamChatTestTools/VirtualTime/VirtualTimer.swift @@ -5,7 +5,7 @@ import Foundation @testable import StreamChat -struct VirtualTimeTimer: StreamChat.Timer { +struct VirtualTimeTimer: TimerScheduling { nonisolated(unsafe) static var time: VirtualTime! static func invalidate() { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Requests/MissingEventsRequestBody_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Requests/MissingEventsRequestBody_Tests.swift index 1c03a3dd70f..738224c66a1 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Requests/MissingEventsRequestBody_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Requests/MissingEventsRequestBody_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class MissingEventsRequestBody_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift b/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift index a0b3d511480..b5f82c2e00e 100644 --- a/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift +++ b/Tests/StreamChatTests/APIClient/RequestDecoder_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class RequestDecoder_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/Audio/StreamAudioRecorder_Tests.swift b/Tests/StreamChatTests/Audio/StreamAudioRecorder_Tests.swift index d8d58264383..0b4fd140bef 100644 --- a/Tests/StreamChatTests/Audio/StreamAudioRecorder_Tests.swift +++ b/Tests/StreamChatTests/Audio/StreamAudioRecorder_Tests.swift @@ -423,8 +423,8 @@ import XCTest line: line ) XCTAssertEqual( - (mockRecorderDelegate.didFailWithErrorWasCalledWithError as? ClientError)?.message, - expectedError().message, + (mockRecorderDelegate.didFailWithErrorWasCalledWithError as? ClientError)?.errorDescription, + expectedError().errorDescription, file: file, line: line ) diff --git a/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift b/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift index 7788c76c2e2..d674a4b82d1 100644 --- a/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift +++ b/Tests/StreamChatTests/Audio/StreamAudioSessionConfigurator_Tests.swift @@ -5,6 +5,7 @@ import AVFoundation @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class StreamAudioSessionConfigurator_Tests: XCTestCase { @@ -46,7 +47,7 @@ final class StreamAudioSessionConfigurator_Tests: XCTestCase { stubAudioSession.stubProperty(\.availableInputs, with: []) XCTAssertThrowsError(try subject.activateRecordingSession()) { error in - XCTAssertEqual("No available audio inputs found.", (error as? AudioSessionConfiguratorError)?.message) + XCTAssertEqual("No available audio inputs found.", (error as? AudioSessionConfiguratorError)?.localizedDescription) } } diff --git a/Tests/StreamChatTests/Audio/StreamAudioWaveformAnalyser_Tests.swift b/Tests/StreamChatTests/Audio/StreamAudioWaveformAnalyser_Tests.swift index 4e2eaea377a..21b4781ea5d 100644 --- a/Tests/StreamChatTests/Audio/StreamAudioWaveformAnalyser_Tests.swift +++ b/Tests/StreamChatTests/Audio/StreamAudioWaveformAnalyser_Tests.swift @@ -102,8 +102,8 @@ final class StreamAudioWaveformAnalyser_Tests: XCTestCase { XCTFail(file: file, line: line) } catch { XCTAssertEqual( - (error as? ClientError)?.message, - expectedError().message, + (error as? ClientError)?.errorDescription, + expectedError().errorDescription, file: file, line: line ) diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift index eda936b79b7..348120be631 100644 --- a/Tests/StreamChatTests/ChatClient_Tests.swift +++ b/Tests/StreamChatTests/ChatClient_Tests.swift @@ -86,7 +86,7 @@ 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, webSocketRequestEncoder: $2, webSocketClient: $3, apiClient: $4, timerType: $5) } env.databaseContainerBuilder = { [config] kind, clientConfig in XCTAssertEqual( @@ -116,7 +116,7 @@ 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, webSocketRequestEncoder: $2, webSocketClient: $3, apiClient: $4, timerType: $5) } env.databaseContainerBuilder = { kind, _ in XCTAssertEqual(kind, .inMemory) @@ -141,7 +141,7 @@ 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, webSocketRequestEncoder: $2, webSocketClient: $3, apiClient: $4, timerType: $5) } env.databaseContainerBuilder = { kind, _ in XCTAssertEqual(.inMemory, kind) @@ -175,15 +175,14 @@ 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) + XCTAssert((webSocket?.init_eventNotificationCenter as? EventPersistentNotificationCenter)?.database === client.databaseContainer) XCTAssertNotNil(webSocket?.init_eventDecoder) // EventDataProcessorMiddleware must be always first - XCTAssert(webSocket?.init_eventNotificationCenter.middlewares[0] is EventDataProcessorMiddleware) + XCTAssert((webSocket?.init_eventNotificationCenter as? EventPersistentNotificationCenter)?.middlewares[0] is EventDataProcessorMiddleware) // Assert Client sets itself as delegate for the request encoder - XCTAssert(webSocket?.init_requestEncoder.connectionDetailsProviderDelegate === client) + XCTAssert(client.webSocketRequestEncoder?.connectionDetailsProviderDelegate === client) } func test_webSocketClient_hasAllMandatoryMiddlewares() throws { @@ -198,7 +197,7 @@ final class ChatClient_Tests: XCTestCase { _ = client.webSocketClient // Assert that mandatory middlewares exists - let middlewares = try XCTUnwrap(testEnv.webSocketClient?.init_eventNotificationCenter.middlewares) + let middlewares = try XCTUnwrap((testEnv.webSocketClient?.init_eventNotificationCenter as? EventPersistentNotificationCenter)?.middlewares) // Assert `EventDataProcessorMiddleware` exists XCTAssert(middlewares.contains(where: { $0 is EventDataProcessorMiddleware })) @@ -475,32 +474,28 @@ final class ChatClient_Tests: XCTestCase { 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.map(AnyEndpoint.init) == expectedWebSocketEndpoint { + expectation.fulfill() } } - - XCTAssertNil(error) } + wait(for: queueAndExpectation.map({ $1 }), timeout: defaultTimeout) } // MARK: - Connect Token Provider @@ -928,7 +923,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) @@ -992,7 +987,7 @@ private class TestEnvironment { @Atomic var eventDecoder: EventDecoder? - @Atomic var notificationCenter: EventNotificationCenter? + @Atomic var notificationCenter: EventPersistentNotificationCenter? @Atomic var connectionRepository: ConnectionRepository_Mock? @@ -1016,9 +1011,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 +1054,7 @@ 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, webSocketRequestEncoder: $2, webSocketClient: $3, apiClient: $4, timerType: $5) return self.connectionRepository! }, backgroundTaskSchedulerBuilder: { @@ -1068,7 +1062,7 @@ private class TestEnvironment { return self.backgroundTaskScheduler! }, timerType: VirtualTimeTimer.self, - connectionRecoveryHandlerBuilder: { _, _, _, _, _, _ in + connectionRecoveryHandlerBuilder: { _, _, _, _, _ in ConnectionRecoveryHandler_Mock() }, authenticationRepositoryBuilder: { @@ -1105,22 +1099,6 @@ extension ChatClient_Tests { } } -private struct Queue { - @Atomic private var storage = [Element]() - mutating func push(_ element: Element) { - _storage.mutate { $0.append(element) } - } - - mutating func pop() -> Element? { - var first: Element? - _storage.mutate { storage in - first = storage.first - storage = Array(storage.dropFirst()) - } - return first - } -} - private extension ChatClientConfig { init() { self = .init(apiKey: APIKey(.unique)) diff --git a/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift b/Tests/StreamChatTests/Controllers/ConnectionController/ConnectionController_Tests.swift index db83ff2a9f5..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 { @@ -84,7 +85,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]) @@ -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..b6c94dc5f13 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: EventPersistentNotificationCenter! 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..f15ab544df6 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: EventNotificationCenter_Mock! // MARK: - Setup diff --git a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift index 0307bf74230..cc2b550a00f 100644 --- a/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/PollsControllers/PollVoteListController_Tests.swift @@ -207,7 +207,7 @@ final class PollVoteListController_Tests: XCTestCase { func test_eventsController_didReceiveEvent_PollVoteCastedEvent_withAnswerVote() { // Create a vote list controller for answers (optionId = nil) - let answerQuery = PollVoteListQuery(pollId: pollId, optionId: nil) + let answerQuery = PollVoteListQuery(pollId: pollId) let answerController = PollVoteListController( query: answerQuery, client: client, @@ -239,7 +239,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - answerController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + answerController.didReceiveEvent(event) // Verify the vote was linked XCTAssertEqual(linkCallCount, 1) @@ -284,7 +284,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - regularController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + regularController.didReceiveEvent(event) // Verify the vote was linked XCTAssertEqual(linkCallCount, 1) @@ -298,7 +298,7 @@ final class PollVoteListController_Tests: XCTestCase { func test_eventsController_didReceiveEvent_PollVoteChangedEvent_withAnswerVote() { // Create a vote list controller for answers (optionId = nil) - let answerQuery = PollVoteListQuery(pollId: pollId, optionId: nil) + let answerQuery = PollVoteListQuery(pollId: pollId) let answerController = PollVoteListController( query: answerQuery, client: client, @@ -330,7 +330,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - answerController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + answerController.didReceiveEvent(event) // Verify the vote was linked XCTAssertEqual(linkCallCount, 1) @@ -375,7 +375,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - regularController.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + regularController.didReceiveEvent(event) // Verify the vote was linked XCTAssertEqual(linkCallCount, 1) @@ -416,7 +416,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + controller.didReceiveEvent(event) // Verify the vote was NOT linked due to different poll ID XCTAssertEqual(linkCallCount, 0) @@ -454,7 +454,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - regularQuery.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + regularQuery.didReceiveEvent(event) // Verify the vote was NOT linked because answer votes should only be linked when optionId is nil XCTAssertEqual(linkCallCount, 0) @@ -492,7 +492,7 @@ final class PollVoteListController_Tests: XCTestCase { } // Simulate receiving the event - controller.eventsController(EventsController(notificationCenter: client.eventNotificationCenter), didReceiveEvent: event) + controller.didReceiveEvent(event) // Verify the vote was NOT linked because option IDs don't match XCTAssertEqual(linkCallCount, 0) @@ -528,7 +528,7 @@ final class PollVoteListController_Tests: XCTestCase { XCTAssertNil(controller.poll) } - func test_pollObserver_notifiesDelegateOnPollUpdate() { + @MainActor func test_pollObserver_notifiesDelegateOnPollUpdate() { // Create initial poll let user = UserPayload.dummy(userId: currentUserId) let initialPoll = dummyPollPayload(id: pollId, user: user) diff --git a/Tests/StreamChatTests/Errors/ClientError_Tests.swift b/Tests/StreamChatTests/Errors/ClientError_Tests.swift index 4b403ea2944..f84594a380d 100644 --- a/Tests/StreamChatTests/Errors/ClientError_Tests.swift +++ b/Tests/StreamChatTests/Errors/ClientError_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class ClientError_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/Errors/ErrorPayload_Tests.swift b/Tests/StreamChatTests/Errors/ErrorPayload_Tests.swift deleted file mode 100644 index 898837671ce..00000000000 --- a/Tests/StreamChatTests/Errors/ErrorPayload_Tests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -import XCTest - -final class ErrorPayload_Tests: XCTestCase { - // MARK: - Invalid token - - func test_isInvalidTokenError_whenCodeIsInsideInvalidTokenRange_returnsTrue() { - // Iterate invalid token codes range - for code in ClosedRange.tokenInvalidErrorCodes { - // Create the error with invalid token code - let error = ErrorPayload( - code: code, - message: .unique, - statusCode: .unique - ) - - // Assert `isInvalidTokenError` returns true - XCTAssertTrue(error.isInvalidTokenError) - } - } - - func test_isInvalidTokenError_whenCodeIsOutsideInvalidTokenRange_returnsFalse() { - // Create array of error codes outside invalid token range - let codesOutsideInvalidTokenRange = [ - ClosedRange.tokenInvalidErrorCodes.lowerBound - 1, - ClosedRange.tokenInvalidErrorCodes.upperBound + 1 - ] - - // Iterate error codes - for code in codesOutsideInvalidTokenRange { - // Create the error with code outside invalid token range - let error = ErrorPayload( - code: code, - message: .unique, - statusCode: .unique - ) - - // Assert `isInvalidTokenError` returns false - XCTAssertFalse(error.isInvalidTokenError) - } - } - - // MARK: - Client error - - func test_isClientError_whenCodeIsInsideClientErrorRange_returnsTrue() { - // Iterate invalid token codes range - for code in ClosedRange.clientErrorCodes { - // Create the error with client error status code - let error = ErrorPayload( - code: .unique, - message: .unique, - statusCode: code - ) - - // Assert `isClientError` returns true - XCTAssertTrue(error.isClientError) - } - } - - func test_isClientError_whenCodeIsOutsideClientErrorRange_returnsFalse() { - // Create array of error codes outside client error range - let codesOutsideClientErrorRange = [ - ClosedRange.clientErrorCodes.lowerBound - 1, - ClosedRange.clientErrorCodes.upperBound + 1 - ] - - // Iterate error codes - for code in codesOutsideClientErrorRange { - // Create the error with code outside invalid token range - let error = ErrorPayload( - code: .unique, - message: .unique, - statusCode: code - ) - - // Assert `isClientError` returns false - XCTAssertFalse(error.isClientError) - } - } -} diff --git a/Tests/StreamChatTests/Query/PinnedMessages/PinnedMessagesPagination_Tests.swift b/Tests/StreamChatTests/Query/PinnedMessages/PinnedMessagesPagination_Tests.swift index 8fa104a796d..c4033690a50 100644 --- a/Tests/StreamChatTests/Query/PinnedMessages/PinnedMessagesPagination_Tests.swift +++ b/Tests/StreamChatTests/Query/PinnedMessages/PinnedMessagesPagination_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class PinnedMessagesPagination_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index e83d022b2ea..38882a412b8 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -4,22 +4,26 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore 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, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -46,6 +50,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -91,7 +96,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) @@ -157,6 +162,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -196,12 +202,11 @@ final class ConnectionRepository_Tests: XCTestCase { let tokenUserId = "123-token-userId" let token = Token(rawValue: "", userId: tokenUserId, expiration: nil) - XCTAssertNil(webSocketClient.connectEndpoint) repository.updateWebSocketEndpoint(with: token, userInfo: nil) - + // UserInfo should take priority XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: tokenUserId) @@ -216,12 +221,11 @@ final class ConnectionRepository_Tests: XCTestCase { let tokenUserId = "123-token-userId" let token = Token(rawValue: "", userId: tokenUserId, expiration: nil) - XCTAssertNil(webSocketClient.connectEndpoint) repository.updateWebSocketEndpoint(with: token, userInfo: userInfo) // UserInfo should take priority XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: userInfoUserId) @@ -232,11 +236,10 @@ final class ConnectionRepository_Tests: XCTestCase { func test_updateWebSocketEndpointWithUserId() throws { let userId = "123-userId" - XCTAssertNil(webSocketClient.connectEndpoint) repository.updateWebSocketEndpoint(with: userId) - + XCTAssertEqual( - webSocketClient.connectEndpoint.map(AnyEndpoint.init), + repository.webSocketConnectEndpoint.map(AnyEndpoint.init), AnyEndpoint( .webSocketConnect( userInfo: UserInfo(id: userId) @@ -257,8 +260,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 +291,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), @@ -302,6 +305,7 @@ final class ConnectionRepository_Tests: XCTestCase { let repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -332,8 +336,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) ] @@ -342,6 +346,7 @@ final class ConnectionRepository_Tests: XCTestCase { let repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -402,7 +407,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 +515,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) @@ -545,6 +550,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: true, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self @@ -561,6 +567,7 @@ final class ConnectionRepository_Tests: XCTestCase { repository = ConnectionRepository( isClientInActiveMode: false, syncRepository: syncRepository, + webSocketRequestEncoder: webSocketRequestEncoder, webSocketClient: webSocketClient, apiClient: apiClient, timerType: DefaultTimer.self diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index 985ad7b3bd9..cd2574f6659 100644 --- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift @@ -4,6 +4,7 @@ @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class MessageRepositoryTests: XCTestCase { diff --git a/Tests/StreamChatTests/StreamChatStressTests/Atomic_StressTests.swift b/Tests/StreamChatTests/StreamChatStressTests/Atomic_StressTests.swift deleted file mode 100644 index b539a7a8b39..00000000000 --- a/Tests/StreamChatTests/StreamChatStressTests/Atomic_StressTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -@testable import StreamChat -import StreamChatTestTools -import XCTest - -final class Atomic_Tests: StressTestCase { - @Atomic var stringAtomicValue: String? - @Atomic var intAtomicValue: Int = 0 - - func test_Atomic_asPropertyWrapper() { - stringAtomicValue = nil - XCTAssertEqual(stringAtomicValue, nil) - - stringAtomicValue = "Luke" - XCTAssertEqual(stringAtomicValue, "Luke") - - _stringAtomicValue { value in - XCTAssertEqual(value, "Luke") - value = nil - } - XCTAssertEqual(stringAtomicValue, nil) - } - - func test_Atomic_usedAsCounter() { - let group = DispatchGroup() - intAtomicValue = 0 - - // Count up to numberOfCycles - for _ in 0..(wrappedValue: [:]) - - for idx in 0..(wrappedValue: [:]) - - let group = DispatchGroup() - for idx in 0..(wrappedValue: [:]) - - for idx in 0..(period: 10, timerType: VirtualTimeTimer.self) { events, _ in - handlerCalls.append(events) - } - - // Prepare some batches of events - let event1 = TestEvent() - let event2 = TestEvent() - - // Add 1st event to batch - batcher.append(event1) - - // Wait a bit less then period - time.run(numberOfSeconds: 1) - // Assert current batch contains expected values - XCTAssertEqual(batcher.currentBatch, [event1]) - // Assert handler is not called yet - XCTAssertEqual(handlerCalls, []) - - // Add 2nd event to batch - batcher.append(event2) - - // Wait a bit less then period - time.run(numberOfSeconds: 1) - // Assert current batch contains expected values - XCTAssertEqual(batcher.currentBatch, [event1, event2]) - // Assert handler is not called yet - XCTAssertEqual(handlerCalls, []) - - // Wait another bit so batch period has passed - time.run(numberOfSeconds: 10) - // Assert handler is called a single time with batched events - XCTAssertEqual(handlerCalls, [[event1, event2]]) - // Assert current batch is empty - XCTAssertTrue(batcher.currentBatch.isEmpty) - - // Clear handler - handlerCalls.removeAll() - - // Wait another bit so another batch period has passed - time.run(numberOfSeconds: 20) - - // Assert handler is not fired again - XCTAssertTrue(handlerCalls.isEmpty) - } - - func test_processImmidiately() { - // Create batcher with long period and keep track of handler calls - var handlerCalls = [[TestEvent]]() - var handlerCompletion: (() -> Void)? - let batcher = Batcher(period: 20, timerType: VirtualTimeTimer.self) { events, completion in - handlerCalls.append(events) - handlerCompletion = completion - } - - // Prepare the event - let event = TestEvent() - - // Append the event - batcher.append(event) - - // Ask to process immidiately - let expectation = expectation(description: "`processImmediately` completion") - batcher.processImmediately { - expectation.fulfill() - } - - // Wait for a small bit of time much less then a period - time.run(numberOfSeconds: 0.1) - - // Assert handler is called sooner - XCTAssertEqual(handlerCalls, [[event]]) - - // Complete batch processing - handlerCompletion?() - - // Assert current batch is empty - XCTAssertEqual(batcher.currentBatch, []) - 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..f6fdf4e708a 100644 --- a/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift +++ b/Tests/StreamChatTests/Utils/InternetConnection/Error+InternetNotAvailable_Tests.swift @@ -3,6 +3,7 @@ // @testable import StreamChat +@testable import StreamCore import XCTest final class Error_Tests: XCTestCase { 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) - } -} diff --git a/Tests/StreamChatTests/Utils/JSONDecoder_Tests.swift b/Tests/StreamChatTests/Utils/JSONDecoder_Tests.swift index 5632eaad3b3..ab516c5f781 100644 --- a/Tests/StreamChatTests/Utils/JSONDecoder_Tests.swift +++ b/Tests/StreamChatTests/Utils/JSONDecoder_Tests.swift @@ -5,6 +5,7 @@ import Foundation @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class JSONDecoder_Tests: XCTestCase { diff --git a/Tests/StreamChatTests/Utils/StreamJSONDecoder_Tests.swift b/Tests/StreamChatTests/Utils/StreamJSONDecoder_Tests.swift index a90030fdaaa..e9f207fdd39 100644 --- a/Tests/StreamChatTests/Utils/StreamJSONDecoder_Tests.swift +++ b/Tests/StreamChatTests/Utils/StreamJSONDecoder_Tests.swift @@ -5,6 +5,7 @@ import Foundation @testable import StreamChat @testable import StreamChatTestTools +@testable import StreamCore import XCTest final class StreamJSONDecoderTests: XCTestCase { 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 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/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) - } -} diff --git a/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift b/Tests/StreamChatTests/WebSocketClient/WebSocketClient_Tests.swift deleted file mode 100644 index 2443a29cf79..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, .waitingForConnectionId) - - // 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)) - } - - func test_callingConnect_whenAlreadyConnected_hasNoEffect() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(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)) - } - } - - func test_disconnect_callsEngine() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(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(connectionId: connectionId)) - } - - // MARK: - Ping Controller - - func test_webSocketPingController_connectionStateDidChange_calledWhenConnectionChanges() { - test_connectionFlow() - AssertAsync.willBeEqual( - pingController.connectionStateDidChange_connectionStates, - [.connecting, .waitingForConnectionId, .connected(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 - .waitingForConnectionId, - .waitingForConnectionId, // duplicate state should be ignored - .connected(connectionId: connectionId), - .connected(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(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, .waitingForConnectionId) - - // 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(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, .waitingForConnectionId) - - // 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 b3df799f438..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(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(.waitingForConnectionId) - 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)) - - 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 200bb318fa7..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(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(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: .waitingForConnectionId) - - 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(.waitingForConnectionId) - ws.simulateConnectionStatus(.connected(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..7af664fb968 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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(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 = EventPersistentNotificationCenter(database: database, manualEventHandler: mockHandler) // Create event logger to check published events let eventLogger = EventLogger(center) diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift index 20d613a9e73..42cc07ca5af 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift @@ -5,6 +5,7 @@ @testable import StreamChat @testable import StreamChatTestTools @testable import StreamChatUI +@testable import StreamCore import StreamSwiftTestHelpers import XCTest