Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-4982] Some groundwork for integrating room lifecycle manager #103

Merged
merged 5 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
fatalError("Not implemented")
}

func get(_: String) -> Channel {
fatalError("Not implemented")
}

func exists(_: String) -> Bool {
fatalError("Not implemented")
}
Expand Down
20 changes: 10 additions & 10 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,27 @@ import Ably
// TODO: remove once we improve this experience in ably-cocoa (https://github.com/ably/ably-cocoa/issues/1967)

internal extension ARTRealtimeChannelProtocol {
func attachAsync() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, _>) in
func attachAsync() async throws(ARTErrorInfo) {
try await withCheckedContinuation { (continuation: CheckedContinuation<Result<Void, ARTErrorInfo>, _>) in
attach { error in
if let error {
continuation.resume(throwing: error)
continuation.resume(returning: .failure(error))
} else {
continuation.resume()
continuation.resume(returning: .success(()))
}
}
}
}.get()
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
}

func detachAsync() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, _>) in
func detachAsync() async throws(ARTErrorInfo) {
try await withCheckedContinuation { (continuation: CheckedContinuation<Result<Void, ARTErrorInfo>, _>) in
detach { error in
if let error {
continuation.resume(throwing: error)
continuation.resume(returning: .failure(error))
} else {
continuation.resume()
continuation.resume(returning: .success(()))
}
}
}
}.get()
}
}
4 changes: 0 additions & 4 deletions Sources/AblyChat/ChatAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ internal final class ChatAPI: Sendable {
self.realtime = realtime
}

internal func getChannel(_ name: String) -> any RealtimeChannelProtocol {
realtime.getChannel(name)
}

// (CHA-M6) Messages should be queryable from a paginated REST API.
internal func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult<Message> {
let endpoint = "\(apiVersion)/rooms/\(roomId)/messages"
Expand Down
9 changes: 3 additions & 6 deletions Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,20 @@ private struct MessageSubscriptionWrapper {
@MainActor
internal final class DefaultMessages: Messages, EmitsDiscontinuities {
private let roomID: String
public let channel: RealtimeChannelProtocol
public nonisolated let channel: RealtimeChannelProtocol
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
private let chatAPI: ChatAPI
private let clientID: String

// TODO: https://github.com/ably-labs/ably-chat-swift/issues/36 - Handle unsubscribing in line with CHA-M4b
// UUID acts as a unique identifier for each listener/subscription. MessageSubscriptionWrapper houses the subscription and the timeserial of when it was attached or resumed.
private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:]

internal nonisolated init(chatAPI: ChatAPI, roomID: String, clientID: String) async {
internal nonisolated init(channel: RealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, clientID: String) async {
self.channel = channel
self.chatAPI = chatAPI
self.roomID = roomID
self.clientID = clientID

// (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel <roomId>::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages.
let messagesChannelName = "\(roomID)::$chat::$chatMessages"
channel = chatAPI.getChannel(messagesChannelName)

// Implicitly handles channel events and therefore listners within this class. Alternative is to explicitly call something like `DefaultMessages.start()` which makes the SDK more cumbersome to interact with. This class is useless without kicking off this flow so I think leaving it here is suitable.
// "Calls to instance method 'handleChannelEvents(roomId:)' from outside of its actor context are implicitly asynchronous" hence the `await` here.
await handleChannelEvents(roomId: roomID)
Expand Down
3 changes: 1 addition & 2 deletions Sources/AblyChat/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ public protocol RealtimeClientProtocol: ARTRealtimeProtocol, Sendable {
public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable {
associatedtype Channel: RealtimeChannelProtocol

// It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this property (https://github.com/ably/ably-cocoa/issues/1968).
// It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this function (https://github.com/ably/ably-cocoa/issues/1968).
func get(_ name: String, options: ARTRealtimeChannelOptions) -> Channel
func get(_ name: String) -> Channel
}

/// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``.
Expand Down
28 changes: 15 additions & 13 deletions Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ internal actor DefaultRoom: Room {
// Exposed for testing.
private nonisolated let realtime: RealtimeClient

/// The channels that contribute to this room.
private let channels: [RoomFeature: RealtimeChannelProtocol]

#if DEBUG
internal nonisolated var testsOnly_realtime: RealtimeClient {
realtime
Expand All @@ -61,13 +64,23 @@ internal actor DefaultRoom: Room {
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
}

channels = Self.createChannels(roomID: roomID, realtime: realtime)

messages = await DefaultMessages(
channel: channels[.messages]!,
chatAPI: chatAPI,
roomID: roomID,
clientID: clientId
lawrence-forooghian marked this conversation as resolved.
Show resolved Hide resolved
)
}

private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] {
maratal marked this conversation as resolved.
Show resolved Hide resolved
.init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.typing, RoomFeature.reactions].map { feature in
let channel = realtime.getChannel(feature.channelNameForRoomID(roomID))
return (feature, channel)
})
}

public nonisolated var presence: any Presence {
fatalError("Not yet implemented")
}
Expand All @@ -84,19 +97,8 @@ internal actor DefaultRoom: Room {
fatalError("Not yet implemented")
}

/// Fetches the channels that contribute to this room.
private func channels() -> [any RealtimeChannelProtocol] {
[
"chatMessages",
"typingIndicators",
"reactions",
].map { suffix in
realtime.channels.get("\(roomID)::$chat::$\(suffix)")
}
}

public func attach() async throws {
for channel in channels() {
for channel in channels.map(\.value) {
do {
try await channel.attachAsync()
} catch {
Expand All @@ -108,7 +110,7 @@ internal actor DefaultRoom: Room {
}

public func detach() async throws {
for channel in channels() {
for channel in channels.map(\.value) {
do {
try await channel.detachAsync()
} catch {
Expand Down
19 changes: 19 additions & 0 deletions Sources/AblyChat/RoomFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,23 @@ internal enum RoomFeature {
case reactions
case occupancy
case typing

internal func channelNameForRoomID(_ roomID: String) -> String {
"\(roomID)::$chat::$\(channelNameSuffix)"
}

private var channelNameSuffix: String {
switch self {
case .messages:
// (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel <roomId>::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages.
"chatMessages"
case .typing:
"typingIndicators"
case .reactions:
"reactions"
case .presence, .occupancy:
// We’ll add these, with reference to the relevant spec points, as we implement these features
fatalError("Don’t know channel name suffix for room feature \(self)")
}
}
}
6 changes: 4 additions & 2 deletions Sources/AblyChat/RoomLifecycleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable {
func emitDiscontinuity(_ error: ARTErrorInfo) async
}

internal actor RoomLifecycleManager<Contributor: RoomLifecycleContributor> {
internal protocol RoomLifecycleManager: Sendable {}

internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor>: RoomLifecycleManager {
// MARK: - Constant properties

private let logger: InternalLogger
Expand Down Expand Up @@ -583,7 +585,7 @@ internal actor RoomLifecycleManager<Contributor: RoomLifecycleContributor> {

/// Executes a function that represents a room lifecycle operation.
///
/// - Note: Note that `RoomLifecycleManager` does not implement any sort of mutual exclusion mechanism that _enforces_ that one room lifecycle operation must wait for another (e.g. it is _not_ a queue); each operation needs to implement its own logic for whether it should proceed in the presence of other in-progress operations.
/// - Note: Note that `DefaultRoomLifecycleManager` does not implement any sort of mutual exclusion mechanism that _enforces_ that one room lifecycle operation must wait for another (e.g. it is _not_ a queue); each operation needs to implement its own logic for whether it should proceed in the presence of other in-progress operations.
///
/// - Parameters:
/// - forcedOperationID: Forces the operation to have a given ID. In combination with the ``testsOnly_subscribeToOperationWaitEvents`` API, this allows tests to verify that one test-initiated operation is waiting for another test-initiated operation.
Expand Down
18 changes: 0 additions & 18 deletions Tests/AblyChatTests/ChatAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,6 @@ import Ably
import Testing

struct ChatAPITests {
// MARK: getChannel Tests

// @spec CHA-M1
@Test
func getChannel_returnsChannel() {
// Given
let realtime = MockRealtime.create(
channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])
)
let chatAPI = ChatAPI(realtime: realtime)

// When
let channel = chatAPI.getChannel("basketball::$chat::$chatMessages")

// Then
#expect(channel.name == "basketball::$chat::$chatMessages")
}

// MARK: sendMessage Tests

// @spec CHA-M3c
Expand Down
50 changes: 14 additions & 36 deletions Tests/AblyChatTests/DefaultMessagesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,15 @@ import Ably
import Testing

struct DefaultMessagesTests {
// @spec CHA-M1
@Test
func init_channelNameIsSetAsMessagesChannelName() async throws {
// clientID value is arbitrary

// Given
let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]))
let chatAPI = ChatAPI(realtime: realtime)

// When
let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")

// Then
await #expect(defaultMessages.channel.name == "basketball::$chat::$chatMessages")
}

@Test
func subscribe_whenChannelIsAttachedAndNoChannelSerial_throwsError() async throws {
// roomId and clientId values are arbitrary

// Given
let realtime = MockRealtime.create(channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")]))
let realtime = MockRealtime.create()
let chatAPI = ChatAPI(realtime: realtime)
let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")
let channel = MockRealtimeChannel()
let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")

// Then
await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: {
Expand All @@ -40,11 +25,10 @@ struct DefaultMessagesTests {
// Message response of succcess with no items, and roomId are arbitrary

// Given
let realtime = MockRealtime.create(
channels: .init(channels: [.init(name: "basketball::$chat::$chatMessages")])
) { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) }
let realtime = MockRealtime.create { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) }
let chatAPI = ChatAPI(realtime: realtime)
let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")
let channel = MockRealtimeChannel()
let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")

// Then
await #expect(throws: Never.self, performing: {
Expand All @@ -60,21 +44,15 @@ struct DefaultMessagesTests {
// all setup values here are arbitrary

// Given
let realtime = MockRealtime.create(
channels: .init(
channels: [
.init(
name: "basketball::$chat::$chatMessages",
properties: .init(
attachSerial: "001",
channelSerial: "001"
)
),
]
)
) { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) }
let realtime = MockRealtime.create { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) }
let chatAPI = ChatAPI(realtime: realtime)
let defaultMessages = await DefaultMessages(chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")
let channel = MockRealtimeChannel(
properties: .init(
attachSerial: "001",
channelSerial: "001"
)
)
let defaultMessages = await DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId")
let subscription = try await defaultMessages.subscribe(bufferingPolicy: .unbounded)
let expectedPaginatedResult = PaginatedResultWrapper<Message>(
paginatedResponse: MockHTTPPaginatedResponse.successGetMessagesWithNoItems,
Expand Down
Loading