From 75094862974c18991e80c4249be892791b048a68 Mon Sep 17 00:00:00 2001 From: Umair Date: Tue, 19 Nov 2024 16:45:19 +0000 Subject: [PATCH] Spec complete for Connection in line with [1] @ commit bf536c8 [1] - https://github.com/ably/specification/pull/227 Note: CHA-CS5a3 has a typo in the spec. Omitted should be emitted. The typo has been fixed in the in-line spec comment in this commit. --- Example/AblyChatExample/ContentView.swift | 16 +++ .../AblyChatExample/Mocks/MockClients.swift | 24 +++- .../AblyChatExample/Mocks/MockRealtime.swift | 118 ++++++++++++++++++ .../Ably+Dependencies.swift | 2 + Sources/AblyChat/ChatClient.swift | 9 +- Sources/AblyChat/Connection.swift | 25 ++++ Sources/AblyChat/DefaultConnection.swift | 104 +++++++++++++++ Sources/AblyChat/Dependencies.swift | 6 + .../AblyChatTests/Mocks/MockConnection.swift | 68 ++++++++++ Tests/AblyChatTests/Mocks/MockRealtime.swift | 9 +- 10 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 Sources/AblyChat/DefaultConnection.swift create mode 100644 Tests/AblyChatTests/Mocks/MockConnection.swift diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 30036b35..79470f8c 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -136,6 +136,7 @@ struct ContentView: View { .tryTask { try await showReactions() } .tryTask { try await showPresence() } .tryTask { try await showOccupancy() } + .tryTask { try await printConnectionStatusChange() } .tryTask { // NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main block just above. if mode == .mock { @@ -144,6 +145,21 @@ struct ContentView: View { } } } + + func printConnectionStatusChange() async throws { + Task { + var sub: Subscription + if mode == .mock { + sub = mockChatClient.connection.onStatusChange(bufferingPolicy: .unbounded) + } else { + sub = liveChatClient.connection.onStatusChange(bufferingPolicy: .unbounded) + } + + for await status in sub { + print("Connection status changed to: \(status.current)") + } + } + } func sendButtonAction() { if newMessage.isEmpty { diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index 9c356e54..d73300df 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -5,17 +5,15 @@ actor MockChatClient: ChatClient { let realtime: RealtimeClient nonisolated let clientOptions: ClientOptions nonisolated let rooms: Rooms + nonisolated let connection: Connection init(realtime: RealtimeClient, clientOptions: ClientOptions?) { self.realtime = realtime self.clientOptions = clientOptions ?? .init() + connection = MockConnection(status: .connected, error: nil) rooms = MockRooms(clientOptions: self.clientOptions) } - nonisolated var connection: any Connection { - fatalError("Not yet implemented") - } - nonisolated var clientID: String { fatalError("Not yet implemented") } @@ -387,3 +385,21 @@ actor MockOccupancy: Occupancy { fatalError("Not yet implemented") } } + +actor MockConnection: Connection { + let status: AblyChat.ConnectionStatus + let error: ARTErrorInfo? + + nonisolated func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription { + let mockSub = MockSubscription(randomElement: { + ConnectionStatusChange(current: .connecting, previous: .connected, retryIn: 1) + }, interval: 5) + + return Subscription(mockAsyncSequence: mockSub) + } + + init(status: AblyChat.ConnectionStatus, error: ARTErrorInfo?) { + self.status = status + self.error = error + } +} diff --git a/Example/AblyChatExample/Mocks/MockRealtime.swift b/Example/AblyChatExample/Mocks/MockRealtime.swift index a5416b67..c57115f2 100644 --- a/Example/AblyChatExample/Mocks/MockRealtime.swift +++ b/Example/AblyChatExample/Mocks/MockRealtime.swift @@ -3,6 +3,8 @@ import AblyChat /// A mock implementation of `RealtimeClientProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { + let connection = Connection() + var device: ARTLocalDevice { fatalError("Not implemented") } @@ -13,6 +15,122 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { let channels = Channels() + final class Connection: ConnectionProtocol { + init(id: String? = nil, key: String? = nil, maxMessageSize: Int = 0, state: ARTRealtimeConnectionState = .closed, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) { + self.id = id + self.key = key + self.maxMessageSize = maxMessageSize + self.state = state + self.errorReason = errorReason + self.recoveryKey = recoveryKey + hash = 0 + superclass = nil + description = "" + } + + let id: String? + + let key: String? + + let maxMessageSize: Int + + let state: ARTRealtimeConnectionState + + let errorReason: ARTErrorInfo? + + let recoveryKey: String? + + func createRecoveryKey() -> String? { + fatalError("Not implemented") + } + + func connect() { + fatalError("Not implemented") + } + + func close() { + fatalError("Not implemented") + } + + func ping(_: @escaping ARTCallback) { + fatalError("Not implemented") + } + + func on(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func once(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func off(_: ARTRealtimeConnectionEvent, listener _: ARTEventListener) { + fatalError("Not implemented") + } + + func off(_: ARTEventListener) { + fatalError("Not implemented") + } + + func off() { + fatalError("Not implemented") + } + + func isEqual(_: Any?) -> Bool { + fatalError("Not implemented") + } + + let hash: Int + + let superclass: AnyClass? + + func `self`() -> Self { + fatalError("Not implemented") + } + + func perform(_: Selector!) -> Unmanaged! { + fatalError("Not implemented") + } + + func perform(_: Selector!, with _: Any!) -> Unmanaged! { + fatalError("Not implemented") + } + + func perform(_: Selector!, with _: Any!, with _: Any!) -> Unmanaged! { + fatalError("Not implemented") + } + + func isProxy() -> Bool { + fatalError("Not implemented") + } + + func isKind(of _: AnyClass) -> Bool { + fatalError("Not implemented") + } + + func isMember(of _: AnyClass) -> Bool { + fatalError("Not implemented") + } + + func conforms(to _: Protocol) -> Bool { + fatalError("Not implemented") + } + + func responds(to _: Selector!) -> Bool { + fatalError("Not implemented") + } + + let description: String + } + final class Channels: RealtimeChannelsProtocol { func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel { fatalError("Not implemented") diff --git a/Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift b/Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift index ae17fde3..057cc2a7 100644 --- a/Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift +++ b/Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift @@ -5,3 +5,5 @@ extension ARTRealtime: RealtimeClientProtocol {} extension ARTRealtimeChannels: RealtimeChannelsProtocol {} extension ARTRealtimeChannel: RealtimeChannelProtocol {} + +extension ARTConnection: ConnectionProtocol {} diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 3827deb6..8fd9d86e 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -16,16 +16,17 @@ public actor DefaultChatClient: ChatClient { public nonisolated let rooms: Rooms private let logger: InternalLogger + // (CHA-CS1) Every chat client has a status, which describes the current status of the connection. + // (CHA-CS4) The chat client must allow its connection status to be observed by clients. + public nonisolated let connection: any Connection + public init(realtime: RealtimeClient, clientOptions: ClientOptions?) { self.realtime = realtime self.clientOptions = clientOptions ?? .init() logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel) let roomFactory = DefaultRoomFactory() rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory) - } - - public nonisolated var connection: any Connection { - fatalError("Not yet implemented") + connection = DefaultConnection(realtime: realtime) } public nonisolated var clientID: String { diff --git a/Sources/AblyChat/Connection.swift b/Sources/AblyChat/Connection.swift index 5efa165e..9dab26d1 100644 --- a/Sources/AblyChat/Connection.swift +++ b/Sources/AblyChat/Connection.swift @@ -8,12 +8,37 @@ public protocol Connection: AnyObject, Sendable { } public enum ConnectionStatus: Sendable { + // (CHA-CS1a) The INITIALIZED status is a default status when the realtime client is first initialized. This value will only (likely) be seen if the realtime client doesn’t have autoconnect turned on. case initialized + // (CHA-CS1b) The CONNECTING status is used when the client is in the process of connecting to Ably servers. case connecting + // (CHA-CS1c) The CONNECTED status is used when the client connected to Ably servers. case connected + // (CHA-CS1d) The DISCONNECTED status is used when the client is not currently connected to Ably servers. This state may be temporary as the underlying Realtime SDK seeks to reconnect. case disconnected + // (CHA-CS1e) The SUSPENDED status is used when the client is in an extended state of disconnection, but will attempt to reconnect. case suspended + // (CHA-CS1f) The FAILED status is used when the client is disconnected from the Ably servers due to some non-retriable failure such as authentication failure. It will not attempt to reconnect. case failed + + internal init(from realtimeConnectionState: ARTRealtimeConnectionState) { + switch realtimeConnectionState { + case .initialized: + self = .initialized + case .connecting: + self = .connecting + case .connected: + self = .connected + case .disconnected: + self = .disconnected + case .suspended: + self = .suspended + case .failed, .closing, .closed: + self = .failed + @unknown default: + self = .failed + } + } } public struct ConnectionStatusChange: Sendable { diff --git a/Sources/AblyChat/DefaultConnection.swift b/Sources/AblyChat/DefaultConnection.swift new file mode 100644 index 00000000..431e5807 --- /dev/null +++ b/Sources/AblyChat/DefaultConnection.swift @@ -0,0 +1,104 @@ +import Ably + +internal final class DefaultConnection: Connection { + // (CHA-CS2a) The chat client must expose its current connection status, a single value from the list in CHA-CS1. + internal let status: ConnectionStatus + // (CHA-CS2b) The chat client must expose the latest error, if any, associated with its current status. + internal let error: ARTErrorInfo? + + private let realtime: any RealtimeClientProtocol + private let timerManager = TimerManager() + + internal init(realtime: any RealtimeClientProtocol) { + self.realtime = realtime + // (CHA-CS3) The initial status and error of the connection will be whatever status the realtime client returns whilst the connection status object is constructed. + status = .init(from: realtime.connection.state) + error = realtime.connection.errorReason + } + + // (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events. + internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + // (CHA-CS5) The chat client must monitor the underlying realtime connection for connection status changes. + realtime.connection.on { [weak self] stateChange in + guard let self else { + return + } + let currentState = ConnectionStatus(from: stateChange.current) + let previousState = ConnectionStatus(from: stateChange.previous) + if currentState == status { + return + } + + // (CHA-CS4a) Connection status update events must contain the newly entered connection status. + // (CHA-CS4b) Connection status update events must contain the previous connection status. + // (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status. + let statusChange = ConnectionStatusChange( + current: currentState, + previous: previousState, + error: stateChange.reason, + retryIn: stateChange.retryIn + ) + + Task { + let isTimerRunning = await timerManager.hasRunningTask() + // (CHA-CS5a) The chat client must suppress transient disconnection events. It is not uncommon for Ably servers to perform connection shedding to balance load, or due to retiring. Clients should not need to concern themselves with transient events. + + // (CHA-CS5a2) If a transient disconnect timer is active and the realtime connection status changes to `DISCONNECTED` or `CONNECTING`, the library must not emit a status change. + if isTimerRunning, currentState == .disconnected || currentState == .connecting { + return + } + + // (CHA-CS5a3) If a transient disconnect timer is active and the realtime connections status changes to `CONNECTED`, `SUSPENDED` or `FAILED`, the library shall cancel the transient disconnect timer. The superseding status change shall be emitted. + if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed { + await timerManager.cancelTimer() + subscription.emit(statusChange) + } + + // (CHA-CS5a1) If the realtime connection status transitions from `CONNECTED` to `DISCONNECTED`, the chat client connection status must not change. A 5 second transient disconnect timer shall be started. + if previousState == .connected, currentState == .disconnected, !isTimerRunning { + await timerManager.setTimer(interval: 5.0) { [timerManager] in + Task { + // (CHA-CS5a4) If a transient disconnect timer expires the library shall emit a connection status change event. This event must contain the current status of of timer expiry, along with the original error that initiated the transient disconnect timer. + await timerManager.cancelTimer() + subscription.emit(statusChange) + } + } + return + } + + if isTimerRunning { + await timerManager.cancelTimer() + } + } + + // (CHA-CS5b) Not withstanding CHA-CS5a. If a connection state event is observed from the underlying realtime library, the client must emit a status change event. The current status of that event shall reflect the status change in the underlying realtime library, along with the accompanying error. + subscription.emit(statusChange) + } + + return subscription + } +} + +internal actor TimerManager { + private var currentTask: Task? + + internal func setTimer(interval: TimeInterval, handler: @escaping @Sendable () -> Void) { + cancelTimer() + + currentTask = Task { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + handler() + } + } + + internal func cancelTimer() { + currentTask?.cancel() + currentTask = nil + } + + internal func hasRunningTask() -> Bool { + currentTask != nil + } +} diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index 38533818..3ed90b3c 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -5,9 +5,13 @@ import Ably /// The `ARTRealtime` class from the ably-cocoa SDK implements this protocol. public protocol RealtimeClientProtocol: ARTRealtimeProtocol, Sendable { associatedtype Channels: RealtimeChannelsProtocol + associatedtype Connection: ConnectionProtocol // It’s not clear to me why ARTRealtimeProtocol doesn’t include this property. I briefly tried adding it but ran into compilation failures that it wasn’t immediately obvious how to fix. var channels: Channels { get } + + // TODO: Expose `Connection` on ARTRealtimeProtocol so it can be used from RealtimeClientProtocol - https://github.com/ably-labs/ably-chat-swift/issues/123 + var connection: Connection { get } } /// Expresses the requirements of the object returned by ``RealtimeClientProtocol.channels``. @@ -21,6 +25,8 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``. public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {} +public protocol ConnectionProtocol: ARTConnectionProtocol, Sendable {} + internal extension RealtimeClientProtocol { // Function to get the channel with merged options func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol { diff --git a/Tests/AblyChatTests/Mocks/MockConnection.swift b/Tests/AblyChatTests/Mocks/MockConnection.swift new file mode 100644 index 00000000..b126d265 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockConnection.swift @@ -0,0 +1,68 @@ +import Ably +import AblyChat + +final class MockConnection: NSObject, ConnectionProtocol { + let id: String? + + let key: String? + + let maxMessageSize: Int = 0 + + let state: ARTRealtimeConnectionState + + let errorReason: ARTErrorInfo? + + let recoveryKey: String? + + init(id: String? = nil, key: String? = nil, state: ARTRealtimeConnectionState = .initialized, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) { + self.id = id + self.key = key + self.state = state + self.errorReason = errorReason + self.recoveryKey = recoveryKey + } + + func createRecoveryKey() -> String? { + fatalError("Not implemented") + } + + func connect() { + fatalError("Not implemented") + } + + func close() { + fatalError("Not implemented") + } + + func ping(_: @escaping ARTCallback) { + fatalError("Not implemented") + } + + func on(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func once(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + fatalError("Not implemented") + } + + func off(_: ARTRealtimeConnectionEvent, listener _: ARTEventListener) { + fatalError("Not implemented") + } + + func off(_: ARTEventListener) { + fatalError("Not implemented") + } + + func off() { + fatalError("Not implemented") + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index a89dd0d4..c6c3fd43 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -4,6 +4,7 @@ import Foundation /// A mock implementation of `ARTRealtimeProtocol`. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5. final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { + let connection: MockConnection let channels: MockChannels let paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? @@ -17,24 +18,29 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { init( channels: MockChannels = .init(channels: []), + connection: MockConnection = .init(), paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? = nil ) { self.channels = channels self.paginatedCallback = paginatedCallback + self.connection = connection } required init(options _: ARTClientOptions) { channels = .init(channels: []) + connection = .init() paginatedCallback = nil } required init(key _: String) { channels = .init(channels: []) + connection = .init() paginatedCallback = nil } required init(token _: String) { channels = .init(channels: []) + connection = .init() paginatedCallback = nil } @@ -45,9 +51,10 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable { */ static func create( channels: MockChannels = MockChannels(channels: []), + connection: MockConnection = MockConnection(), paginatedCallback: (@Sendable () -> (ARTHTTPPaginatedResponse?, ARTErrorInfo?))? = nil ) -> MockRealtime { - MockRealtime(channels: channels, paginatedCallback: paginatedCallback) + MockRealtime(channels: channels, connection: connection, paginatedCallback: paginatedCallback) } func time(_: @escaping ARTDateTimeCallback) {