diff --git a/CHANGELOG.md b/CHANGELOG.md index c117e1ad90a..79dd8f27096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add an option to configure a reconnection timeout [#3303](https://github.com/GetStream/stream-chat-swift/pull/3303) +### 🐞 Fixed +- Improve the stability of the reconnection process [#3303](https://github.com/GetStream/stream-chat-swift/pull/3303) +- Fix invalid token errors considered as recoverable errors [#3303](https://github.com/GetStream/stream-chat-swift/pull/3303) ### 🔄 Changed - Dropped iOS 12 support [#3285](https://github.com/GetStream/stream-chat-swift/pull/3285) - Increase QoS for `Throttler` and `Debouncer` to `utility` [#3295](https://github.com/GetStream/stream-chat-swift/issues/3295) -- Improve reliability of accessing database models in controller provided completion handlers [#3304](https://github.com/GetStream/stream-chat-swift/issues/3304) +- Improve reliability of accessing data in controllers' completion handlers [#3304](https://github.com/GetStream/stream-chat-swift/issues/3304) + +## StreamChatUI +### 🐞 Fixed +- Fix Channel List not hiding error state view when data is available [#3303](https://github.com/GetStream/stream-chat-swift/pull/3303) ## StreamChatUI ### ✅ Added diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index db1af0e7b99..16d14ab5a76 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -192,6 +192,7 @@ class AppConfigViewController: UITableViewController { enum ChatClientConfigOption: String, CaseIterable { case isLocalStorageEnabled case staysConnectedInBackground + case reconnectionTimeout case shouldShowShadowedMessages case deletedMessagesVisibility case isChannelAutomaticFilteringEnabled @@ -347,6 +348,9 @@ class AppConfigViewController: UITableViewController { cell.accessoryView = makeSwitchButton(chatClientConfig.staysConnectedInBackground) { [weak self] newValue in self?.chatClientConfig.staysConnectedInBackground = newValue } + case .reconnectionTimeout: + cell.detailTextLabel?.text = chatClientConfig.reconnectionTimeout.map { "\($0)" } ?? "None" + cell.accessoryType = .disclosureIndicator case .shouldShowShadowedMessages: cell.accessoryView = makeSwitchButton(chatClientConfig.shouldShowShadowedMessages) { [weak self] newValue in self?.chatClientConfig.shouldShowShadowedMessages = newValue @@ -371,6 +375,8 @@ class AppConfigViewController: UITableViewController { switch option { case .deletedMessagesVisibility: pushDeletedMessagesVisibilitySelectorVC() + case .reconnectionTimeout: + pushReconnectionTimeoutSelectorVC() default: break } @@ -581,6 +587,24 @@ class AppConfigViewController: UITableViewController { navigationController?.pushViewController(selectorViewController, animated: true) } + private func pushReconnectionTimeoutSelectorVC() { + let selectorViewController = OptionsSelectorViewController( + options: [nil, 15.0, 30.0, 45.0, 60.0], + initialSelectedOptions: [chatClientConfig.reconnectionTimeout], + allowsMultipleSelection: false, + optionFormatter: { option in + option.map { "\($0)" } ?? "None" + } + ) + selectorViewController.didChangeSelectedOptions = { [weak self] options in + guard let selectedOption = options.first else { return } + self?.chatClientConfig.reconnectionTimeout = selectedOption + self?.tableView.reloadData() + } + + navigationController?.pushViewController(selectorViewController, animated: true) + } + private func showTokenDetailsAlert() { let alert = UIAlertController( title: "Token Refreshing", diff --git a/DemoApp/Shared/StreamChatWrapper.swift b/DemoApp/Shared/StreamChatWrapper.swift index 99717d65ca6..e7d04c1656f 100644 --- a/DemoApp/Shared/StreamChatWrapper.swift +++ b/DemoApp/Shared/StreamChatWrapper.swift @@ -22,15 +22,17 @@ final class StreamChatWrapper { var client: ChatClient? // ChatClient config - var config: ChatClientConfig = { - var config = ChatClientConfig(apiKeyString: apiKeyString) + var config: ChatClientConfig { + didSet { + client = ChatClient(config: config) + } + } + + private init() { + config = ChatClientConfig(apiKeyString: apiKeyString) config.shouldShowShadowedMessages = true config.applicationGroupIdentifier = applicationGroupIdentifier config.urlSessionConfiguration.httpAdditionalHeaders = ["Custom": "Example"] - return config - }() - - private init() { configureUI() } } diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListErrorView.swift b/DemoApp/StreamChat/Components/DemoChatChannelListErrorView.swift new file mode 100644 index 00000000000..491258b5177 --- /dev/null +++ b/DemoApp/StreamChat/Components/DemoChatChannelListErrorView.swift @@ -0,0 +1,20 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChatUI +import UIKit + +class DemoChatChannelListErrorView: ChatChannelListErrorView { + override func show() { + UIView.animate(withDuration: 0.5) { + self.isHidden = false + } + } + + override func hide() { + UIView.animate(withDuration: 0.5) { + self.isHidden = true + } + } +} diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift index c22e0af43f8..961581b92cc 100644 --- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift +++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift @@ -61,6 +61,7 @@ extension StreamChatWrapper { Components.default.messageActionsVC = DemoChatMessageActionsVC.self Components.default.messageLayoutOptionsResolver = DemoChatMessageLayoutOptionsResolver() Components.default.reactionsSorting = ReactionSorting.byFirstReactionAt + Components.default.channelListErrorView = DemoChatChannelListErrorView.self // Customize MarkdownFormatter let defaultFormatter = DefaultMarkdownFormatter() diff --git a/Sources/StreamChat/APIClient/RequestDecoder.swift b/Sources/StreamChat/APIClient/RequestDecoder.swift index 5dc90e6b4fb..c59017f0ab4 100644 --- a/Sources/StreamChat/APIClient/RequestDecoder.swift +++ b/Sources/StreamChat/APIClient/RequestDecoder.swift @@ -58,8 +58,8 @@ struct DefaultRequestDecoder: RequestDecoder { throw ClientError.Unknown("Unknown error. Server response: \(httpResponse).") } - if serverError.isInvalidTokenError { - log.info("Request failed because of an experied token.", subsystems: .httpRequests) + if serverError.isExpiredTokenError { + log.info("Request failed because of an expired token.", subsystems: .httpRequests) throw ClientError.ExpiredToken() } diff --git a/Sources/StreamChat/ChatClient+Environment.swift b/Sources/StreamChat/ChatClient+Environment.swift index 236b16a7ee7..77782a8bbe0 100644 --- a/Sources/StreamChat/ChatClient+Environment.swift +++ b/Sources/StreamChat/ChatClient+Environment.swift @@ -103,7 +103,8 @@ extension ChatClient { _ extensionLifecycle: NotificationExtensionLifecycle, _ backgroundTaskScheduler: BackgroundTaskScheduler?, _ internetConnection: InternetConnection, - _ keepConnectionAliveInBackground: Bool + _ keepConnectionAliveInBackground: Bool, + _ reconnectionTimeoutHandler: StreamTimer? ) -> ConnectionRecoveryHandler = { DefaultConnectionRecoveryHandler( webSocketClient: $0, @@ -114,7 +115,8 @@ extension ChatClient { internetConnection: $5, reconnectionStrategy: DefaultRetryStrategy(), reconnectionTimerType: DefaultTimer.self, - keepConnectionAliveInBackground: $6 + keepConnectionAliveInBackground: $6, + reconnectionTimeoutHandler: $7 ) } diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 124cd785fef..3a91fe5ec46 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -257,7 +257,8 @@ public class ChatClient { extensionLifecycle, environment.backgroundTaskSchedulerBuilder(), environment.internetConnection(eventNotificationCenter, environment.internetMonitor), - config.staysConnectedInBackground + config.staysConnectedInBackground, + config.reconnectionTimeout.map { ScheduledStreamTimer(interval: $0, fireOnStart: false, repeats: false) } ) } @@ -288,6 +289,8 @@ public class ChatClient { tokenProvider: @escaping TokenProvider, completion: ((Error?) -> Void)? = nil ) { + connectionRecoveryHandler?.start() + authenticationRepository.connectUser( userInfo: userInfo, tokenProvider: tokenProvider, @@ -379,6 +382,7 @@ public class ChatClient { userInfo: UserInfo, completion: ((Error?) -> Void)? = nil ) { + connectionRecoveryHandler?.start() authenticationRepository.connectGuestUser(userInfo: userInfo, completion: { completion?($0) }) } @@ -402,6 +406,7 @@ public class ChatClient { /// Connects an anonymous user /// - Parameter completion: The completion that will be called once the **first** user session for the given token is setup. public func connectAnonymousUser(completion: ((Error?) -> Void)? = nil) { + connectionRecoveryHandler?.start() authenticationRepository.connectAnonymousUser( completion: { completion?($0) } ) @@ -437,6 +442,7 @@ public class ChatClient { /// Disconnects the chat client from the chat servers. No further updates from the servers /// are received. public func disconnect(completion: @escaping () -> Void) { + connectionRecoveryHandler?.stop() connectionRepository.disconnect(source: .userInitiated) { log.info("The `ChatClient` has been disconnected.", subsystems: .webSocket) completion() diff --git a/Sources/StreamChat/Config/ChatClientConfig.swift b/Sources/StreamChat/Config/ChatClientConfig.swift index f239e958cdf..76e2e3a3f28 100644 --- a/Sources/StreamChat/Config/ChatClientConfig.swift +++ b/Sources/StreamChat/Config/ChatClientConfig.swift @@ -183,6 +183,10 @@ public struct ChatClientConfig { /// It controls how long (in seconds) a network task should wait for additional data to arrive before giving up public var timeoutIntervalForRequest: TimeInterval = 30 + /// The maximum time in seconds the SDK will wait until it reconnects successfully, in case it was disconnected by a recoverable error. + /// By default there is no timeout, so the SDK will keep trying to connect for an undetermined time. + public var reconnectionTimeout: TimeInterval? + /// Enable/Disable local filtering for Channel lists. When enabled, /// whenever a new channel is created,/updated the SDK will try to /// match the channel list filter automatically. diff --git a/Sources/StreamChat/Errors/ErrorPayload.swift b/Sources/StreamChat/Errors/ErrorPayload.swift index 57ef782327a..629d3e7160b 100644 --- a/Sources/StreamChat/Errors/ErrorPayload.swift +++ b/Sources/StreamChat/Errors/ErrorPayload.swift @@ -57,7 +57,7 @@ extension ErrorPayload { 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.expiredToken...StreamErrorCode.invalidTokenSignature + static let tokenInvalidErrorCodes: Self = StreamErrorCode.notYetValidToken...StreamErrorCode.invalidTokenSignature /// The range of HTTP request status codes for client errors. static let clientErrorCodes: Self = 400...499 diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift index cc32d79f6f1..d9b1ece47e2 100644 --- a/Sources/StreamChat/Repositories/ConnectionRepository.swift +++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift @@ -140,10 +140,8 @@ class ConnectionRepository { case let .connected(connectionId: id): shouldNotifyConnectionIdWaiters = true connectionId = id - case let .disconnected(source) where source.serverError?.isInvalidTokenError == true: - if source.serverError?.isExpiredTokenError == true { - onExpiredToken() - } + case let .disconnected(source) where source.serverError?.isExpiredTokenError == true: + onExpiredToken() shouldNotifyConnectionIdWaiters = false connectionId = nil case .disconnected: diff --git a/Sources/StreamChat/Utils/StreamTimer/ScheduledStreamTimer.swift b/Sources/StreamChat/Utils/StreamTimer/ScheduledStreamTimer.swift index 4993c01d30b..f4ef303ea37 100644 --- a/Sources/StreamChat/Utils/StreamTimer/ScheduledStreamTimer.swift +++ b/Sources/StreamChat/Utils/StreamTimer/ScheduledStreamTimer.swift @@ -5,17 +5,22 @@ import Foundation public class ScheduledStreamTimer: StreamTimer { - let interval: TimeInterval var runLoop = RunLoop.current var timer: Foundation.Timer? public var onChange: (() -> Void)? - public var isRunning: Bool { - timer?.isValid ?? false - } + let interval: TimeInterval + let fireOnStart: Bool + let repeats: Bool - public init(interval: TimeInterval) { + public init(interval: TimeInterval, fireOnStart: Bool = true, repeats: Bool = true) { self.interval = interval + self.fireOnStart = fireOnStart + self.repeats = repeats + } + + public var isRunning: Bool { + timer?.isValid ?? false } public func start() { @@ -23,12 +28,14 @@ public class ScheduledStreamTimer: StreamTimer { timer = Foundation.Timer.scheduledTimer( withTimeInterval: interval, - repeats: true + repeats: repeats ) { _ in self.onChange?() } runLoop.add(timer!, forMode: .common) - timer?.fire() + if fireOnStart { + timer?.fire() + } } public func stop() { diff --git a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift b/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift index bc34d19d381..50a7607cd8a 100644 --- a/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift +++ b/Sources/StreamChat/WebSocketClient/ConnectionStatus.swift @@ -42,9 +42,7 @@ extension ConnectionStatus { self = .disconnecting case let .disconnected(source): - let isWaitingForReconnect = webSocketConnectionState.isAutomaticReconnectionEnabled || source.serverError? - .isInvalidTokenError == true - + let isWaitingForReconnect = webSocketConnectionState.isAutomaticReconnectionEnabled self = isWaitingForReconnect ? .connecting : .disconnected(error: source.serverError) } } @@ -55,10 +53,13 @@ typealias ConnectionId = String /// A web socket connection state. enum WebSocketConnectionState: Equatable { /// Provides additional information about the source of disconnecting. - enum DisconnectionSource: Equatable { + 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) @@ -128,8 +129,9 @@ enum WebSocketConnectionState: Equatable { return false } - if serverInitiatedError.isClientError { - // Don't reconnect on client side errors + 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 } } @@ -141,6 +143,8 @@ enum WebSocketConnectionState: Equatable { return true case .userInitiated: return false + case .timeout: + return false } } } diff --git a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift index 14b1366c788..8d889aebd3a 100644 --- a/Sources/StreamChat/WebSocketClient/WebSocketClient.swift +++ b/Sources/StreamChat/WebSocketClient/WebSocketClient.swift @@ -142,6 +142,17 @@ class WebSocketClient { eventsBatcher.processImmediately(completion: completion) } } + + func timeout() { + let previousState = connectionState + connectionState = .disconnected(source: .timeout(from: previousState)) + engineQueue.async { [engine, eventsBatcher] in + engine?.disconnect() + + eventsBatcher.processImmediately {} + } + log.error("Connection timed out. `\(connectionState)", subsystems: .webSocket) + } } protocol ConnectionStateDelegate: AnyObject { diff --git a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift index 1c2613cea16..14d99290a01 100644 --- a/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift +++ b/Sources/StreamChat/Workers/Background/ConnectionRecoveryHandler.swift @@ -6,7 +6,10 @@ 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 {} +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. @@ -33,6 +36,7 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { private var reconnectionStrategy: RetryStrategy private var reconnectionTimer: TimerControl? private let keepConnectionAliveInBackground: Bool + private var reconnectionTimeoutHandler: StreamTimer? // MARK: - Init @@ -45,7 +49,8 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { internetConnection: InternetConnection, reconnectionStrategy: RetryStrategy, reconnectionTimerType: Timer.Type, - keepConnectionAliveInBackground: Bool + keepConnectionAliveInBackground: Bool, + reconnectionTimeoutHandler: StreamTimer? ) { self.webSocketClient = webSocketClient self.eventNotificationCenter = eventNotificationCenter @@ -56,13 +61,21 @@ final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler { self.reconnectionStrategy = reconnectionStrategy self.reconnectionTimerType = reconnectionTimerType self.keepConnectionAliveInBackground = keepConnectionAliveInBackground + self.reconnectionTimeoutHandler = reconnectionTimeoutHandler + } + func start() { subscribeOnNotifications() } - deinit { + func stop() { unsubscribeFromNotifications() cancelReconnectionTimer() + reconnectionTimeoutHandler?.stop() + } + + deinit { + stop() } } @@ -81,6 +94,11 @@ private extension DefaultConnectionRecoveryHandler { name: .internetConnectionAvailabilityDidChange, object: nil ) + + reconnectionTimeoutHandler?.onChange = { [weak self] in + self?.webSocketClient.timeout() + self?.cancelReconnectionTimer() + } } func unsubscribeFromNotifications() { @@ -102,7 +120,9 @@ extension DefaultConnectionRecoveryHandler { backgroundTaskScheduler?.endTask() - reconnectIfNeeded() + if canReconnectFromOffline { + webSocketClient.connect() + } } private func appDidEnterBackground() { @@ -136,12 +156,16 @@ extension DefaultConnectionRecoveryHandler { } @objc private func internetConnectionAvailabilityDidChange(_ notification: Notification) { - guard let isAvailable = notification.internetConnectionStatus?.isAvailable else { return } + guard let isAvailable = notification.internetConnectionStatus?.isAvailable else { + return + } log.debug("Internet -> \(isAvailable ? "✅" : "❌")", subsystems: .webSocket) if isAvailable { - reconnectIfNeeded() + if canReconnectFromOffline { + webSocketClient.connect() + } } else { disconnectIfNeeded() } @@ -153,6 +177,9 @@ extension DefaultConnectionRecoveryHandler { switch state { case .connecting: cancelReconnectionTimer() + if reconnectionTimeoutHandler?.isRunning == false { + reconnectionTimeoutHandler?.start() + } case .connected: extensionLifecycle.setAppState(isReceivingEvents: true) @@ -160,6 +187,7 @@ extension DefaultConnectionRecoveryHandler { syncRepository.syncLocalState { log.info("Local state sync completed", subsystems: .offlineSupport) } + reconnectionTimeoutHandler?.stop() case .disconnected: extensionLifecycle.setAppState(isReceivingEvents: false) @@ -168,6 +196,24 @@ extension DefaultConnectionRecoveryHandler { 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 @@ -197,37 +243,6 @@ private extension DefaultConnectionRecoveryHandler { } } -// MARK: - Reconnection - -private extension DefaultConnectionRecoveryHandler { - func reconnectIfNeeded() { - guard canReconnectAutomatically else { return } - - webSocketClient.connect() - } - - var canReconnectAutomatically: Bool { - guard webSocketClient.connectionState.isAutomaticReconnectionEnabled else { - log.debug("Reconnection is not required (\(webSocketClient.connectionState))", subsystems: .webSocket) - return false - } - - guard internetConnection.status.isAvailable else { - log.debug("Reconnection is not possible (internet ❌)", 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 - } -} - // MARK: - Reconnection Timer private extension DefaultConnectionRecoveryHandler { @@ -248,7 +263,9 @@ private extension DefaultConnectionRecoveryHandler { onFire: { [weak self] in log.debug("Timer 🔥", subsystems: .webSocket) - self?.reconnectIfNeeded() + if self?.canReconnectAutomatically == true { + self?.webSocketClient.connect() + } } ) } @@ -261,4 +278,20 @@ private extension DefaultConnectionRecoveryHandler { 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/StreamChatUI/ChatChannelList/ChatChannelListVC.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift index 5ef3f598f76..329fbe2e6c2 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelListVC.swift @@ -437,6 +437,7 @@ open class ChatChannelListVC: _ViewController, case .remoteDataFetched: isLoading = false shouldHideEmptyView = !controller.channels.isEmpty + channelListErrorView.hide() case .localDataFetchFailed, .remoteDataFetchFailed: shouldHideEmptyView = emptyView.isHidden isLoading = false diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index cf2a192825f..dae7223ce03 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1591,6 +1591,7 @@ AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; }; AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; + ADA2D64A2C46B66E001D2B44 /* DemoChatChannelListErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */; }; ADA3572F269C807A004AD8E9 /* ChatChannelHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */; }; ADA5A0F8276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; }; ADA5A0F9276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; }; @@ -4232,6 +4233,7 @@ AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = ""; }; AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = ""; }; AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = ""; }; ADA3572D269C562A004AD8E9 /* ChatChannelHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelHeaderView.swift; sourceTree = ""; }; ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListDateSeparatorView.swift; sourceTree = ""; }; ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewUserMentionsHandler_Mock.swift; sourceTree = ""; }; @@ -6620,6 +6622,7 @@ A3227E77284A4CAD00EBE6CC /* DemoChatMessageContentView.swift */, 79B8B64A285CBDC00059FB2D /* DemoChatMessageLayoutOptionsResolver.swift */, A3227E71284A4BF700EBE6CC /* HiddenChannelListVC.swift */, + ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */, AD053B982B33581A003612B6 /* CustomAttachments */, A3227E67284A4AA400EBE6CC /* Banner */, ); @@ -10813,6 +10816,7 @@ 7933060B256FF94800FBB586 /* DemoChatChannelListRouter.swift in Sources */, AD82903D2A7C5A8F00396782 /* DemoChatChannelListItemView.swift in Sources */, A3227E69284A4AE800EBE6CC /* AvatarView.swift in Sources */, + ADA2D64A2C46B66E001D2B44 /* DemoChatChannelListErrorView.swift in Sources */, A3227E5B284A489000EBE6CC /* UIViewController+Alert.swift in Sources */, A3227E6D284A4B6A00EBE6CC /* UserCredentialsCell.swift in Sources */, 84A33ABA28F86B8500CEC8FD /* StreamChatWrapper+DemoApp.swift in Sources */, diff --git a/StreamChatUITestsAppUITests/Robots/UserRobot.swift b/StreamChatUITestsAppUITests/Robots/UserRobot.swift index f14b1fd3195..4f78ab68c09 100644 --- a/StreamChatUITestsAppUITests/Robots/UserRobot.swift +++ b/StreamChatUITestsAppUITests/Robots/UserRobot.swift @@ -536,6 +536,8 @@ extension UserRobot { @discardableResult func setConnectivity(to state: SwitchState) -> Self { setSwitchState(Settings.isConnected.element, state: state) + Settings.isConnected.element.wait(timeout: 2) + return self } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 967df496de8..26e97f4e063 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -148,6 +148,9 @@ extension ChatClient { deletedMessagesVisibility: $4, shouldShowShadowedMessages: $5 ) + }, + internetConnection: { center, _ in + InternetConnection_Mock(notificationCenter: center) }, authenticationRepositoryBuilder: AuthenticationRepository_Mock.init ) diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift index 7b9599bfc12..169931a5619 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/WebSocketClient/WebSocketClient_Mock.swift @@ -13,14 +13,16 @@ final class WebSocketClient_Mock: WebSocketClient { let init_eventNotificationCenter: EventNotificationCenter let init_environment: WebSocketClient.Environment - @Atomic var connect_calledCounter = 0 + var connect_calledCounter = 0 var connect_called: Bool { connect_calledCounter > 0 } - @Atomic var disconnect_calledCounter = 0 + var disconnect_calledCounter = 0 var disconnect_source: WebSocketConnectionState.DisconnectionSource? var disconnect_called: Bool { disconnect_calledCounter > 0 } var disconnect_completion: (() -> Void)? + var timeout_callCount = 0 + var mockedConnectionState: WebSocketConnectionState? @@ -64,18 +66,22 @@ final class WebSocketClient_Mock: WebSocketClient { } override func connect() { - _connect_calledCounter { $0 += 1 } + connect_calledCounter += 1 } override func disconnect( source: WebSocketConnectionState.DisconnectionSource = .userInitiated, completion: @escaping () -> Void ) { - _disconnect_calledCounter { $0 += 1 } + disconnect_calledCounter += 1 disconnect_source = source disconnect_completion = completion } + override func timeout() { + timeout_callCount += 1 + } + var mockEventsBatcher: EventBatcher_Mock { eventsBatcher as! EventBatcher_Mock } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift index 28f3bab794e..26ddbf51cdb 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/Background/ConnectionRecoveryHandler_Mock.swift @@ -7,6 +7,17 @@ import XCTest /// Mock implementation of `ConnectionRecoveryHandler` final class ConnectionRecoveryHandler_Mock: ConnectionRecoveryHandler { + var startCallCount = 0 + var stopCallCount = 0 + + func start() { + startCallCount += 1 + } + + func stop() { + stopCallCount += 1 + } + lazy var mock_webSocketClientDidUpdateConnectionState = MockFunc.mock(for: webSocketClient) func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) { diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift index a1b5ca9fcaa..58a1e70192a 100644 --- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift @@ -276,7 +276,7 @@ final class ConnectionRepository_Tests: XCTestCase { (.disconnecting(source: .noPongReceived), .disconnecting), (.disconnected(source: .userInitiated), .disconnected(error: nil)), (.disconnected(source: .systemInitiated), .connecting), - (.disconnected(source: .serverInitiated(error: invalidTokenError)), .connecting) + (.disconnected(source: .serverInitiated(error: invalidTokenError)), .disconnected(error: invalidTokenError)) ] for (webSocketState, connectionStatus) in pairs { @@ -287,7 +287,13 @@ final class ConnectionRepository_Tests: XCTestCase { func test_handleConnectionUpdate_shouldNotifyWaitersWhenNeeded() { let invalidTokenError = ClientError(with: ErrorPayload( - code: .random(in: ClosedRange.tokenInvalidErrorCodes), + code: StreamErrorCode.accessKeyInvalid, + message: .unique, + statusCode: .unique + )) + + let expiredTokenError = ClientError(with: ErrorPayload( + code: StreamErrorCode.expiredToken, message: .unique, statusCode: .unique )) @@ -301,7 +307,8 @@ final class ConnectionRepository_Tests: XCTestCase { (.disconnecting(source: .noPongReceived), false), (.disconnected(source: .userInitiated), true), (.disconnected(source: .systemInitiated), true), - (.disconnected(source: .serverInitiated(error: invalidTokenError)), false) + (.disconnected(source: .serverInitiated(error: invalidTokenError)), true), + (.disconnected(source: .serverInitiated(error: expiredTokenError)), false) ] for (webSocketState, shouldNotify) in pairs { diff --git a/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift b/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift index 7be0bf5e659..0000cf81d60 100644 --- a/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift +++ b/Tests/StreamChatTests/WebSocketClient/ConnectionStatus_Tests.swift @@ -26,7 +26,7 @@ final class ChatClientConnectionStatus_Tests: XCTestCase { (.disconnected(source: .noPongReceived), .connecting), (.disconnected(source: .serverInitiated(error: nil)), .connecting), (.disconnected(source: .serverInitiated(error: testError)), .connecting), - (.disconnected(source: .serverInitiated(error: invalidTokenError)), .connecting), + (.disconnected(source: .serverInitiated(error: invalidTokenError)), .disconnected(error: invalidTokenError)), (.connected(connectionId: .unique), .connected), (.disconnecting(source: .noPongReceived), .disconnecting), (.disconnecting(source: .serverInitiated(error: testError)), .disconnecting), @@ -143,6 +143,23 @@ final class WebSocketConnectionState_Tests: XCTestCase { 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( diff --git a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift index 4623d1ccdf8..7584c85b4f9 100644 --- a/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift +++ b/Tests/StreamChatTests/Workers/Background/ConnectionRecoveryHandler_Tests.swift @@ -14,6 +14,7 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { var mockBackgroundTaskScheduler: BackgroundTaskScheduler_Mock! var mockRetryStrategy: RetryStrategy_Spy! var mockTime: VirtualTime { VirtualTimeTimer.time } + var mockReconnectionTimeoutHandler: ScheduledStreamTimer_Mock! override func setUp() { super.setUp() @@ -23,7 +24,9 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { mockChatClient = ChatClient_Mock(config: .init(apiKeyString: .unique)) mockBackgroundTaskScheduler = BackgroundTaskScheduler_Mock() mockRetryStrategy = RetryStrategy_Spy() + mockRetryStrategy.mock_nextRetryDelay.returns(5) mockInternetConnection = .init(notificationCenter: mockChatClient.eventNotificationCenter) + mockReconnectionTimeoutHandler = ScheduledStreamTimer_Mock() } override func tearDown() { @@ -43,6 +46,14 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { super.tearDown() } + func test_reconnectionTimeoutHandler_onChange_shouldTimeout() { + handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) + mockReconnectionTimeoutHandler.onChange?() + + XCTAssertEqual(mockChatClient.mockWebSocketClient.timeout_callCount, 1) + XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + } + /// keepConnectionAliveInBackground == false /// /// 1. internet -> OFF (no disconnect, no bg task, no timer) @@ -61,6 +72,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Assert no reconnect timer XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // Internet -> ON mockInternetConnection.monitorMock.status = .available(.great) @@ -86,6 +99,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Assert no reconnect timer XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // App -> foreground mockBackgroundTaskScheduler.simulateAppGoingToForeground() @@ -119,6 +134,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Assert no reconnect timer XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // Internet -> ON mockInternetConnection.monitorMock.status = .available(.great) @@ -152,6 +169,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Assert no reconnect timer XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // App -> foregorund mockBackgroundTaskScheduler.simulateAppGoingToForeground() @@ -162,7 +181,7 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { /// keepConnectionAliveInBackground == false /// /// 1. ws -> connected - /// 2. internet -> OFF (disconnect, no bg task, no timer) + /// 2. internet -> OFF (no bg task, no timer) /// 3. internet -> ON (reconnect) func test_socketIsConnected_appBackgroundForeground() { // Create handler passive in background @@ -174,13 +193,13 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Internet -> OFF mockInternetConnection.monitorMock.status = .unavailable - // 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) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // Disconnect (system initiated) disconnectWebSocket(source: .systemInitiated) @@ -213,6 +232,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Assert no reconnect timer XCTAssertTrue(mockTime.scheduledTimers.isEmpty) + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 + // App -> foregorund mockBackgroundTaskScheduler.simulateAppGoingToForeground() @@ -298,10 +319,10 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { /// /// 1. ws -> connected /// 2. app -> background (no disconnect, background task is started, no timer) - /// 3. internet -> OFF (disconnect) + /// 3. internet -> OFF /// 4. internet -> ON (no reconnect in background) /// 5. internet -> OFF (no disconnect) - /// 6. app -> foregorund (no reconnect without internet) + /// 6. app -> foregorund (reconnect) /// 7. internet -> ON (reconnect) func test_socketIsConnected_appBackgroundInternetOffOnOffAppForegroundInternetOn() { // Create handler active in background @@ -323,19 +344,17 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // Internet -> OFF mockInternetConnection.monitorMock.status = .unavailable - // Assert disconnection is system initiated - XCTAssertEqual(mockChatClient.mockWebSocketClient.disconnect_source, .systemInitiated) - // Disconnect (system initiated) disconnectWebSocket(source: .systemInitiated) - // Reset disconnect calls count + // Reset calls counts mockChatClient.mockWebSocketClient.disconnect_calledCounter = 0 + mockChatClient.mockWebSocketClient.connect_calledCounter = 0 // Internet -> ON mockInternetConnection.monitorMock.status = .available(.great) - // Assert no reconnect in backround + // Assert no reconnect in background XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) // Internet -> OFF @@ -344,9 +363,6 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { // App -> foregorund mockBackgroundTaskScheduler.simulateAppGoingToForeground() - // Assert no reconnect without internet connection - XCTAssertFalse(mockChatClient.mockWebSocketClient.connect_called) - // Internet -> ON mockInternetConnection.monitorMock.status = .available(.great) @@ -495,6 +511,31 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) + XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 0) + } + + func test_webSocketStateUpdate_connecting_whenTimeout_whenNotRunning_shouldStartTimeout() { + handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) + mockReconnectionTimeoutHandler.isRunning = false + + // Simulate connection update + handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) + + XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) + XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) + XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 1) + } + + func test_webSocketStateUpdate_connecting_whenTimeout_whenRunning_shouldNotStartTimeout() { + handler = makeConnectionRecoveryHandler(keepConnectionAliveInBackground: false, withReconnectionTimeout: true) + mockReconnectionTimeoutHandler.isRunning = true + + // Simulate connection update + handler.webSocketClient(mockChatClient.mockWebSocketClient, didUpdateConnectionState: .connecting) + + XCTAssertNotCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository) + XCTAssertNil(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents) + XCTAssertEqual(mockReconnectionTimeoutHandler.startCallCount, 0) } func test_webSocketStateUpdate_connected() { @@ -506,6 +547,19 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { XCTAssertCall(RetryStrategy_Spy.Signature.resetConsecutiveFailures, on: mockRetryStrategy, times: 1) XCTAssertCall("syncLocalState(completion:)", on: mockChatClient.mockSyncRepository, times: 1) XCTAssert(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents == true) + XCTAssertEqual(mockReconnectionTimeoutHandler.stopCallCount, 0) + } + + 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) + XCTAssert(mockChatClient.mockExtensionLifecycle.receivedIsReceivingEvents == true) + XCTAssertEqual(mockReconnectionTimeoutHandler.stopCallCount, 1) } func test_webSocketStateUpdate_disconnected_userInitiated() { @@ -582,7 +636,8 @@ final class ConnectionRecoveryHandler_Tests: XCTestCase { private extension ConnectionRecoveryHandler_Tests { func makeConnectionRecoveryHandler( - keepConnectionAliveInBackground: Bool + keepConnectionAliveInBackground: Bool, + withReconnectionTimeout: Bool = false ) -> DefaultConnectionRecoveryHandler { let handler = DefaultConnectionRecoveryHandler( webSocketClient: mockChatClient.mockWebSocketClient, @@ -593,8 +648,10 @@ private extension ConnectionRecoveryHandler_Tests { internetConnection: mockInternetConnection, reconnectionStrategy: mockRetryStrategy, reconnectionTimerType: VirtualTimeTimer.self, - keepConnectionAliveInBackground: keepConnectionAliveInBackground + keepConnectionAliveInBackground: keepConnectionAliveInBackground, + reconnectionTimeoutHandler: withReconnectionTimeout ? mockReconnectionTimeoutHandler : nil ) + handler.start() // Make a handler a delegate to simlulate real life chain when // connection changes are propagated back to the handler.