diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index 8890d60..c9231f1 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.coroutine.test) + testImplementation(libs.bundles.ktor.client) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.junit) diff --git a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt index a86dd8e..2242bab 100644 --- a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt +++ b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt @@ -79,6 +79,11 @@ enum class ErrorCodes(val errorCode: Int) { */ RoomIsReleased(102_103), + /** + * Room was released before the operation could complete. + */ + RoomReleasedBeforeOperationCompleted(102_106), + /** * Cannot perform operation because the previous operation failed. */ diff --git a/chat-android/src/main/java/com/ably/chat/Messages.kt b/chat-android/src/main/java/com/ably/chat/Messages.kt index ee5567a..2125991 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -225,7 +225,7 @@ internal class DefaultMessages( private val roomId: String, private val realtimeChannels: AblyRealtime.Channels, private val chatApi: ChatApi, -) : Messages, ContributesToRoomLifecycleImpl(), ResolvedContributor { +) : Messages, ContributesToRoomLifecycleImpl() { override val featureName: String = "messages" @@ -243,8 +243,6 @@ internal class DefaultMessages( override val channel = realtimeChannels.get(messagesChannelName, ChatChannelOptions()) - override val contributor: ContributesToRoomLifecycle = this - override val attachmentErrorCode: ErrorCodes = ErrorCodes.MessagesAttachmentFailed override val detachmentErrorCode: ErrorCodes = ErrorCodes.MessagesDetachmentFailed diff --git a/chat-android/src/main/java/com/ably/chat/Occupancy.kt b/chat-android/src/main/java/com/ably/chat/Occupancy.kt index 65fd7e9..975b231 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -61,14 +61,12 @@ data class OccupancyEvent( internal class DefaultOccupancy( private val messages: Messages, -) : Occupancy, ContributesToRoomLifecycleImpl(), ResolvedContributor { +) : Occupancy, ContributesToRoomLifecycleImpl() { override val featureName: String = "occupancy" override val channel = messages.channel - override val contributor: ContributesToRoomLifecycle = this - override val attachmentErrorCode: ErrorCodes = ErrorCodes.OccupancyAttachmentFailed override val detachmentErrorCode: ErrorCodes = ErrorCodes.OccupancyDetachmentFailed diff --git a/chat-android/src/main/java/com/ably/chat/Presence.kt b/chat-android/src/main/java/com/ably/chat/Presence.kt index e3363c4..da905b9 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -2,12 +2,18 @@ package com.ably.chat -import android.text.PrecomputedText.Params -import io.ably.lib.types.ErrorInfo +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.Presence.GET_CLIENTID +import io.ably.lib.realtime.Presence.GET_CONNECTIONID +import io.ably.lib.realtime.Presence.GET_WAITFORSYNC +import io.ably.lib.types.Param import io.ably.lib.types.PresenceMessage -import io.ably.lib.realtime.Channel as AblyRealtimeChannel +import io.ably.lib.realtime.Presence as PubSubPresence +import io.ably.lib.realtime.Presence.PresenceListener as PubSubPresenceListener -typealias PresenceData = Any +typealias PresenceData = JsonElement /** * This interface is used to interact with presence in a chat room: subscribing to presence events, @@ -20,14 +26,15 @@ interface Presence : EmitsDiscontinuities { * Get the underlying Ably realtime channel used for presence in this chat room. * @returns The realtime channel. */ - val channel: AblyRealtimeChannel + val channel: Channel /** - * Method to get list of the current online users and returns the latest presence messages associated to it. - * @param {Ably.RealtimePresenceParams} params - Parameters that control how the presence set is retrieved. - * @returns {Promise} or upon failure, the promise will be rejected with an [[Ably.ErrorInfo]] object which explains the error. + * Method to get list of the current online users and returns the latest presence messages associated to it. + * @param {Ably.RealtimePresenceParams} params - Parameters that control how the presence set is retrieved. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. + * @returns {List} */ - suspend fun get(params: List): List + suspend fun get(waitForSync: Boolean = true, clientId: String? = null, connectionId: String? = null): List /** * Method to check if user with supplied clientId is online @@ -39,23 +46,23 @@ interface Presence : EmitsDiscontinuities { /** * Method to join room presence, will emit an enter event to all subscribers. Repeat calls will trigger more enter events. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ - suspend fun enter(data: PresenceData?) + suspend fun enter(data: PresenceData? = null) /** * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ - suspend fun update(data: PresenceData?) + suspend fun update(data: PresenceData? = null) /** * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ - suspend fun leave(data: PresenceData?) + suspend fun leave(data: PresenceData? = null) /** * Subscribe the given listener to all presence events. @@ -87,7 +94,7 @@ data class PresenceMember( /** * The data associated with the presence member. */ - val data: PresenceData, + val data: PresenceData?, /** * The current state of the presence member. @@ -122,50 +129,83 @@ data class PresenceEvent( /** * The timestamp of the presence event. */ - val timestamp: Int, + val timestamp: Long, /** * The data associated with the presence event. */ - val data: PresenceData, + val data: PresenceData?, ) internal class DefaultPresence( - private val messages: Messages, -) : Presence, ContributesToRoomLifecycleImpl(), ResolvedContributor { + private val clientId: String, + override val channel: Channel, + private val presence: PubSubPresence, +) : Presence, ContributesToRoomLifecycleImpl() { override val featureName = "presence" - override val channel = messages.channel - - override val contributor: ContributesToRoomLifecycle = this - override val attachmentErrorCode: ErrorCodes = ErrorCodes.PresenceAttachmentFailed override val detachmentErrorCode: ErrorCodes = ErrorCodes.PresenceDetachmentFailed - override suspend fun get(params: List): List { - TODO("Not yet implemented") + suspend fun get(params: List): List { + val usersOnPresence = presence.getCoroutine(params) + return usersOnPresence.map { user -> + PresenceMember( + clientId = user.clientId, + action = user.action, + data = (user.data as? JsonObject)?.get("userCustomData"), + updatedAt = user.timestamp, + ) + } } - override suspend fun isUserPresent(clientId: String): Boolean { - TODO("Not yet implemented") + override suspend fun get(waitForSync: Boolean, clientId: String?, connectionId: String?): List { + val params = buildList { + if (waitForSync) add(Param(GET_WAITFORSYNC, true)) + clientId?.let { add(Param(GET_CLIENTID, it)) } + connectionId?.let { add(Param(GET_CONNECTIONID, it)) } + } + return get(params) } + override suspend fun isUserPresent(clientId: String): Boolean = presence.getCoroutine(Param(GET_CLIENTID, clientId)).isNotEmpty() + override suspend fun enter(data: PresenceData?) { - TODO("Not yet implemented") + presence.enterClientCoroutine(clientId, wrapInUserCustomData(data)) } override suspend fun update(data: PresenceData?) { - TODO("Not yet implemented") + presence.updateClientCoroutine(clientId, wrapInUserCustomData(data)) } override suspend fun leave(data: PresenceData?) { - TODO("Not yet implemented") + presence.leaveClientCoroutine(clientId, wrapInUserCustomData(data)) } override fun subscribe(listener: Presence.Listener): Subscription { - TODO("Not yet implemented") + val presenceListener = PubSubPresenceListener { + val presenceEvent = PresenceEvent( + action = it.action, + clientId = it.clientId, + timestamp = it.timestamp, + data = (it.data as? JsonObject)?.get("userCustomData"), + ) + listener.onEvent(presenceEvent) + } + + presence.subscribe(presenceListener) + + return Subscription { + presence.unsubscribe(presenceListener) + } + } + + private fun wrapInUserCustomData(data: PresenceData?) = data?.let { + JsonObject().apply { + add("userCustomData", data) + } } override fun release() { diff --git a/chat-android/src/main/java/com/ably/chat/Room.kt b/chat-android/src/main/java/com/ably/chat/Room.kt index 4930fc7..97418da 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -115,7 +115,7 @@ interface Room { internal class DefaultRoom( override val roomId: String, override val options: RoomOptions, - realtimeClient: RealtimeClient, + val realtimeClient: RealtimeClient, chatApi: ChatApi, val logger: LogHandler?, ) : Room { @@ -130,81 +130,122 @@ internal class DefaultRoom( private val roomScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(roomId)) + private val clientId: String + get() = realtimeClient.auth.clientId + override val messages = DefaultMessages( roomId = roomId, realtimeChannels = realtimeClient.channels, chatApi = chatApi, ) - override val presence = DefaultPresence( - messages = messages, - ) + private var _presence: Presence? = null + override val presence: Presence + get() { + if (_presence == null) { + throw ablyException("Presence is not enabled for this room", ErrorCodes.BadRequest) + } + return _presence as Presence + } - override val typing = DefaultTyping( - roomId = roomId, - realtimeClient = realtimeClient, - ) + private var _reactions: RoomReactions? = null + override val reactions: RoomReactions + get() { + if (_reactions == null) { + throw ablyException("Reactions are not enabled for this room", ErrorCodes.BadRequest) + } + return _reactions as RoomReactions + } - override val reactions = DefaultRoomReactions( - roomId = roomId, - clientId = realtimeClient.auth.clientId, - realtimeChannels = realtimeClient.channels, - ) + private var _typing: Typing? = null + override val typing: Typing + get() { + if (_typing == null) { + throw ablyException("Typing is not enabled for this room", ErrorCodes.BadRequest) + } + return _typing as Typing + } - override val occupancy = DefaultOccupancy( - messages = messages, - ) - private var _lifecycleManager: RoomLifecycleManager? = null + private var _occupancy: Occupancy? = null + override val occupancy: Occupancy + get() { + if (_occupancy == null) { + throw ablyException("Occupancy is not enabled for this room", ErrorCodes.BadRequest) + } + return _occupancy as Occupancy + } - private val _statusLifecycle = DefaultRoomLifecycle(logger) - internal val statusLifecycle: DefaultRoomLifecycle - get() = _statusLifecycle + private val statusLifecycle = DefaultRoomLifecycle(logger) override val status: RoomStatus - get() = _statusLifecycle.status + get() = statusLifecycle.status override val error: ErrorInfo? - get() = _statusLifecycle.error + get() = statusLifecycle.error + + private var lifecycleManager: RoomLifecycleManager init { - /** - * TODO - * Initialize features based on provided RoomOptions. - * By default, only messages feature should be initialized. - * Currently, all features are initialized by default. - */ - val features = listOf(messages, presence, typing, reactions, occupancy) - _lifecycleManager = RoomLifecycleManager(roomScope, _statusLifecycle, features, _logger) - /** - * TODO - * Make sure previous release op. for same was a success. - * Make sure channels were removed using realtime.channels.release(contributor.channel.name); - * Once this is a success, set room to initialized, if not set it to failed and throw error. - * Note that impl. can change based on recent proposed changes to chat-room-lifecycle DR. - */ - this._statusLifecycle.setStatus(RoomStatus.Initialized) + options.validateRoomOptions() + + val roomFeatures = mutableListOf(messages) + + options.presence?.let { + val presenceContributor = DefaultPresence( + clientId = clientId, + channel = messages.channel, + presence = messages.channel.presence, + ) + roomFeatures.add(presenceContributor) + _presence = presenceContributor + } + + options.typing?.let { + val typingContributor = DefaultTyping( + roomId = roomId, + realtimeClient = realtimeClient, + ) + roomFeatures.add(typingContributor) + _typing = typingContributor + } + + options.reactions?.let { + val reactionsContributor = DefaultRoomReactions( + roomId = roomId, + clientId = clientId, + realtimeChannels = realtimeClient.channels, + ) + roomFeatures.add(reactionsContributor) + _reactions = reactionsContributor + } + + options.occupancy?.let { + val occupancyContributor = DefaultOccupancy( + messages = messages, + ) + roomFeatures.add(occupancyContributor) + _occupancy = occupancyContributor + } + + lifecycleManager = RoomLifecycleManager(roomScope, statusLifecycle, roomFeatures, _logger) } - override fun onStatusChange(listener: RoomLifecycle.Listener): Subscription = _statusLifecycle.onChange(listener) + override fun onStatusChange(listener: RoomLifecycle.Listener): Subscription = + statusLifecycle.onChange(listener) override fun offAllStatusChange() { - _statusLifecycle.offAll() + statusLifecycle.offAll() } override suspend fun attach() { - if (_lifecycleManager == null) { - // TODO - wait for room to be initialized inside init - } - _lifecycleManager?.attach() + lifecycleManager.attach() } override suspend fun detach() { - messages.channel.detachCoroutine() - typing.channel.detachCoroutine() - reactions.channel.detachCoroutine() + lifecycleManager.detach() } override suspend fun release() { - _lifecycleManager?.release() + lifecycleManager.release() } } diff --git a/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt b/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt index 260c140..f4836ea 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt @@ -64,18 +64,6 @@ abstract class ContributesToRoomLifecycleImpl : ContributesToRoomLifecycle { } } -/** - * This interface represents a feature that contributes to the room lifecycle and - * exposes its channel directly. Objects of this type are created by awaiting the - * channel promises of all the {@link ContributesToRoomLifecycle} objects. - * - * @internal - */ -interface ResolvedContributor { - val channel: AblyRealtimeChannel - val contributor: ContributesToRoomLifecycle -} - /** * The order of precedence for lifecycle operations, passed to PriorityQueueExecutor which allows * us to ensure that internal operations take precedence over user-driven operations. @@ -89,13 +77,13 @@ enum class LifecycleOperationPrecedence(val priority: Int) { /** * A map of contributors to pending discontinuity events. */ -typealias DiscontinuityEventMap = MutableMap +typealias DiscontinuityEventMap = MutableMap /** * An internal interface that represents the result of a room attachment operation. */ interface RoomAttachmentResult : NewRoomStatus { - val failedFeature: ResolvedContributor? + val failedFeature: ContributesToRoomLifecycle? val exception: AblyException } @@ -104,8 +92,8 @@ class DefaultRoomAttachmentResult : RoomAttachmentResult { override val status: RoomStatus get() = statusField - internal var failedFeatureField: ResolvedContributor? = null - override val failedFeature: ResolvedContributor? + internal var failedFeatureField: ContributesToRoomLifecycle? = null + override val failedFeature: ContributesToRoomLifecycle? get() = failedFeatureField internal var errorField: ErrorInfo? = null @@ -132,7 +120,7 @@ class DefaultRoomAttachmentResult : RoomAttachmentResult { class RoomLifecycleManager( private val roomScope: CoroutineScope, lifecycle: DefaultRoomLifecycle, - contributors: List, + contributors: List, logger: LogHandler? = null, ) { @@ -144,7 +132,7 @@ class RoomLifecycleManager( /** * The features that contribute to the room status. */ - private var _contributors: List = contributors + private var _contributors: List = contributors /** * Logger for RoomLifeCycleManager @@ -179,7 +167,7 @@ class RoomLifecycleManager( * * Used to control whether we should trigger discontinuity events. */ - private val _firstAttachesCompleted = mutableMapOf() + private val _firstAttachesCompleted = mutableMapOf() /** * Are we in the process of releasing the room? @@ -222,7 +210,7 @@ class RoomLifecycleManager( * Spec: CHA-RL5 */ @SuppressWarnings("CognitiveComplexMethod") - private suspend fun doRetry(contributor: ResolvedContributor) { + private suspend fun doRetry(contributor: ContributesToRoomLifecycle) { // CHA-RL5a - Handle the channel wind-down for other channels var result = kotlin.runCatching { doChannelWindDown(contributor) } while (result.isFailure) { @@ -261,7 +249,7 @@ class RoomLifecycleManager( ) } // No need to catch errors, rather they should propagate to caller method - return@coroutineScope doRetry(failedFeature as ResolvedContributor) + return@coroutineScope doRetry(failedFeature as ContributesToRoomLifecycle) } // We attached, huzzah! } @@ -288,7 +276,9 @@ class RoomLifecycleManager( /** * CHA-RL5f, CHA-RL5e */ - private suspend fun listenToChannelAttachOrFailure(contributor: ResolvedContributor) = suspendCancellableCoroutine { continuation -> + private suspend fun listenToChannelAttachOrFailure( + contributor: ContributesToRoomLifecycle, + ) = suspendCancellableCoroutine { continuation -> // CHA-RL5f val resumeIfAttached = { if (continuation.isActive) { @@ -415,9 +405,9 @@ class RoomLifecycleManager( attachResult.throwable = ex attachResult.failedFeatureField = feature attachResult.errorField = ErrorInfo( - "failed to attach ${feature.contributor.featureName} feature${feature.channel.errorMessage}", + "failed to attach ${feature.featureName} feature${feature.channel.errorMessage}", HttpStatusCodes.InternalServerError, - feature.contributor.attachmentErrorCode.errorCode, + feature.attachmentErrorCode.errorCode, ) // The current feature should be in one of two states, it will be either suspended or failed @@ -449,7 +439,7 @@ class RoomLifecycleManager( // Iterate the pending discontinuity events and trigger them for ((contributor, error) in _pendingDiscontinuityEvents) { - contributor.contributor.discontinuityDetected(error) + contributor.discontinuityDetected(error) } _pendingDiscontinuityEvents.clear() return attachResult @@ -481,8 +471,8 @@ class RoomLifecycleManager( * */ @SuppressWarnings("CognitiveComplexMethod", "ComplexCondition") - private suspend fun doChannelWindDown(except: ResolvedContributor? = null) = coroutineScope { - _contributors.map { contributor: ResolvedContributor -> + private suspend fun doChannelWindDown(except: ContributesToRoomLifecycle? = null) = coroutineScope { + _contributors.map { contributor: ContributesToRoomLifecycle -> async { // CHA-RL5a1 - If its the contributor we want to wait for a conclusion on, then we should not detach it // Unless we're in a failed state, in which case we should detach it @@ -511,9 +501,9 @@ class RoomLifecycleManager( _statusLifecycle.status !== RoomStatus.Released ) { val contributorError = ErrorInfo( - "failed to detach ${contributor.contributor.featureName} feature${contributor.channel.errorMessage}", + "failed to detach ${contributor.featureName} feature${contributor.channel.errorMessage}", HttpStatusCodes.InternalServerError, - contributor.contributor.detachmentErrorCode.errorCode, + contributor.detachmentErrorCode.errorCode, ) _statusLifecycle.setStatus(RoomStatus.Failed, contributorError) throw AblyException.fromErrorInfo(throwable, contributorError) @@ -699,7 +689,7 @@ class RoomLifecycleManager( */ @Suppress("RethrowCaughtException") private suspend fun doRelease() = coroutineScope { - _contributors.map { contributor: ResolvedContributor -> + _contributors.map { contributor: ContributesToRoomLifecycle -> async { // CHA-RL3e - Failed channels, we can ignore if (contributor.channel.state == ChannelState.failed) { @@ -720,7 +710,7 @@ class RoomLifecycleManager( // CHA-RL3h - underlying Realtime Channels are released from the core SDK prevent leakage _contributors.forEach { - it.contributor.release() + it.release() } _releaseInProgress = false _statusLifecycle.setStatus(RoomStatus.Released) // CHA-RL3g diff --git a/chat-android/src/main/java/com/ably/chat/RoomOptions.kt b/chat-android/src/main/java/com/ably/chat/RoomOptions.kt index ae2af90..cec8378 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomOptions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomOptions.kt @@ -28,7 +28,19 @@ data class RoomOptions( * {@link RoomOptionsDefaults.occupancy} to enable occupancy with default options. */ val occupancy: OccupancyOptions? = null, -) +) { + companion object { + /** + * Supports all room options with default values + */ + val default = RoomOptions( + typing = TypingOptions(), + presence = PresenceOptions(), + reactions = RoomReactionsOptions, + occupancy = OccupancyOptions, + ) + } +} /** * Represents the presence options for a chat room. @@ -58,9 +70,9 @@ data class TypingOptions( /** * The timeout for typing events in milliseconds. If typing.start() is not called for this amount of time, a stop * typing event will be fired, resulting in the user being removed from the currently typing set. - * @defaultValue 10000 + * @defaultValue 5000 */ - val timeoutMs: Long = 10_000, + val timeoutMs: Long = 5000, ) /** @@ -72,3 +84,12 @@ object RoomReactionsOptions * Represents the occupancy options for a chat room. */ object OccupancyOptions + +/** + * Throws AblyException for invalid room configuration. + */ +fun RoomOptions.validateRoomOptions() { + if (typing != null && typing.timeoutMs <= 0) { + throw ablyException("Typing timeout must be greater than 0", ErrorCodes.InvalidRequestBody) + } +} diff --git a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt index 72f6ab5..863cbb6 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -107,7 +107,7 @@ internal class DefaultRoomReactions( roomId: String, private val clientId: String, private val realtimeChannels: AblyRealtime.Channels, -) : RoomReactions, ContributesToRoomLifecycleImpl(), ResolvedContributor { +) : RoomReactions, ContributesToRoomLifecycleImpl() { override val featureName = "reactions" @@ -115,8 +115,6 @@ internal class DefaultRoomReactions( override val channel: AblyRealtimeChannel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) - override val contributor: ContributesToRoomLifecycle = this - override val attachmentErrorCode: ErrorCodes = ErrorCodes.ReactionsAttachmentFailed override val detachmentErrorCode: ErrorCodes = ErrorCodes.ReactionsDetachmentFailed diff --git a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt index b2e79b7..d5730fe 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt @@ -10,11 +10,6 @@ import io.ably.lib.util.Log.LogHandler * The different states that a room can be in throughout its lifecycle. */ enum class RoomStatus(val stateName: String) { - /** - * The library is currently initializing the room. - */ - Initializing("initializing"), - /** * (CHA-RS1a) * A temporary state for when the library is first initialized. @@ -182,7 +177,8 @@ class RoomStatusEventEmitter : EventEmitter( class DefaultRoomLifecycle(private val logger: LogHandler? = null) : InternalRoomLifecycle { private val _logger = logger - private var _status = RoomStatus.Initializing + + private var _status = RoomStatus.Initialized // CHA-RS3 override val status: RoomStatus get() = _status diff --git a/chat-android/src/main/java/com/ably/chat/Typing.kt b/chat-android/src/main/java/com/ably/chat/Typing.kt index 42dd2e9..8738024 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -78,7 +78,7 @@ data class TypingEvent(val currentlyTyping: Set) internal class DefaultTyping( roomId: String, private val realtimeClient: RealtimeClient, -) : Typing, ContributesToRoomLifecycleImpl(), ResolvedContributor { +) : Typing, ContributesToRoomLifecycleImpl() { private val typingIndicatorsChannelName = "$roomId::\$chat::\$typingIndicators" @@ -86,8 +86,6 @@ internal class DefaultTyping( override val channel = realtimeClient.channels.get(typingIndicatorsChannelName, ChatChannelOptions()) - override val contributor: ContributesToRoomLifecycle = this - override val attachmentErrorCode: ErrorCodes = ErrorCodes.TypingAttachmentFailed override val detachmentErrorCode: ErrorCodes = ErrorCodes.TypingDetachmentFailed diff --git a/chat-android/src/main/java/com/ably/chat/Utils.kt b/chat-android/src/main/java/com/ably/chat/Utils.kt index 434a33d..f1cdc01 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -1,13 +1,19 @@ package com.ably.chat +import com.google.gson.JsonElement import io.ably.lib.realtime.Channel import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.AblyException import io.ably.lib.types.ChannelOptions import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Param import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import io.ably.lib.realtime.Presence as PubSubPresence const val AGENT_PARAMETER_NAME = "agent" @@ -50,6 +56,63 @@ suspend fun Channel.publishCoroutine(message: PubSubMessage) = suspendCoroutine ) } +suspend fun PubSubPresence.getCoroutine(param: Param) = withContext(Dispatchers.IO) { + get(param) +} + +@Suppress("SpreadOperator") +suspend fun PubSubPresence.getCoroutine(params: List) = withContext(Dispatchers.IO) { + get(*params.toTypedArray()) +} + +suspend fun PubSubPresence.enterClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + enterClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + +suspend fun PubSubPresence.updateClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + updateClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + +suspend fun PubSubPresence.leaveClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + leaveClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + val Channel.errorMessage: String get() = if (reason == null) { "" @@ -132,3 +195,10 @@ internal class DeferredValue { return result } } + +fun ablyException( + errorMessage: String, + code: ErrorCodes, + statusCode: Int = HttpStatusCodes.BadRequest, + cause: Throwable? = null, +): AblyException = AblyException.fromErrorInfo(cause, ErrorInfo(errorMessage, statusCode, code.errorCode)) diff --git a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt new file mode 100644 index 0000000..c43f421 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt @@ -0,0 +1,144 @@ +package com.ably.chat + +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.Presence.PresenceListener +import io.ably.lib.realtime.buildRealtimeChannel +import io.ably.lib.types.PresenceMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import io.ably.lib.realtime.Presence as PubSubPresence + +class PresenceTest { + private val pubSubChannel = spyk(buildRealtimeChannel("room1::\$chat::\$messages")) + private val pubSubPresence = mockk(relaxed = true) + private lateinit var presence: DefaultPresence + + @Before + fun setUp() { + presence = DefaultPresence( + clientId = "client1", + channel = pubSubChannel, + presence = pubSubPresence, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if there is no data`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = null, + ), + presenceEvent, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if there is no 'userCustomData'`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + data = JsonObject() + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = null, + ), + presenceEvent, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if 'userCustomData' is primitive`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + data = JsonObject().apply { + addProperty("userCustomData", "user") + } + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = JsonPrimitive("user"), + ), + presenceEvent, + ) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/Sandbox.kt b/chat-android/src/test/java/com/ably/chat/Sandbox.kt new file mode 100644 index 0000000..0bae074 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/Sandbox.kt @@ -0,0 +1,61 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import io.ably.lib.realtime.AblyRealtime +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType + +val client = HttpClient(CIO) { + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 4) + exponentialDelay() + } +} + +class Sandbox private constructor(val appId: String, val apiKey: String) { + companion object { + suspend fun createInstance(): Sandbox { + val response: HttpResponse = client.post("https://sandbox-rest.ably.io/apps") { + contentType(ContentType.Application.Json) + setBody(loadAppCreationRequestBody().toString()) + } + val body = JsonParser.parseString(response.bodyAsText()) + + return Sandbox( + appId = body.asJsonObject["appId"].asString, + // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels" + apiKey = body.asJsonObject["keys"].asJsonArray[5].asJsonObject["keyStr"].asString, + ) + } + } +} + +internal fun Sandbox.createSandboxChatClient(): DefaultChatClient { + val realtime = createSandboxRealtime(apiKey) + return DefaultChatClient(realtime, ClientOptions()) +} + +internal fun Sandbox.createSandboxRealtime(chatClientId: String = "sandbox-client"): AblyRealtime = + AblyRealtime( + io.ably.lib.types.ClientOptions().apply { + key = apiKey + environment = "sandbox" + clientId = chatClientId + }, + ) + +private suspend fun loadAppCreationRequestBody(): JsonElement = + JsonParser.parseString( + client.get("https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/test-resources/test-app-setup.json") { + contentType(ContentType.Application.Json) + }.bodyAsText(), + ).asJsonObject.get("post_apps") diff --git a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt new file mode 100644 index 0000000..577dfdb --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt @@ -0,0 +1,40 @@ +package com.ably.chat + +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +class SandboxTest { + + private lateinit var sandbox: Sandbox + + @Before + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + + @Test + @Ignore + fun `should return empty list of presence members if nobody is entered`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val room = chatClient.rooms.get(UUID.randomUUID().toString()) + room.attach() + val members = room.presence.get() + assertEquals(0, members.size) + } + + @Test + @Ignore + fun `should return yourself as presence member after you entered`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val room = chatClient.rooms.get(UUID.randomUUID().toString()) + room.attach() + room.presence.enter() + val members = room.presence.get() + assertEquals(1, members.size) + assertEquals("sandbox-client", members.first().clientId) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt b/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt new file mode 100644 index 0000000..b782a72 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt @@ -0,0 +1,92 @@ +package com.ably.chat.room + +import com.ably.chat.ChatApi +import com.ably.chat.DefaultRoom +import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatus +import com.ably.chat.TypingOptions +import com.ably.utils.createMockRealtimeClient +import io.ably.lib.types.AblyException +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Chat rooms are configurable, so as to enable or disable certain features. + * When requesting a room, options as to which features should be enabled, and + * the configuration they should take, must be provided + * Spec: CHA-RC2 + */ +class ConfigureRoomOptionsTest { + + @Test + fun `(CHA-RC2a) If a room is requested with a negative typing timeout, an ErrorInfo with code 40001 must be thrown`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + + // Room success when positive typing timeout + val room = DefaultRoom("1234", RoomOptions(typing = TypingOptions(timeoutMs = 100)), mockRealtimeClient, chatApi, null) + Assert.assertNotNull(room) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // Room failure when negative timeout + val exception = assertThrows(AblyException::class.java) { + DefaultRoom("1234", RoomOptions(typing = TypingOptions(timeoutMs = -1)), mockRealtimeClient, chatApi, null) + } + Assert.assertEquals("Typing timeout must be greater than 0", exception.errorInfo.message) + Assert.assertEquals(40_001, exception.errorInfo.code) + Assert.assertEquals(400, exception.errorInfo.statusCode) + } + + @Test + fun `(CHA-RC2b) Attempting to use disabled feature must result in an ErrorInfo with code 40000 being thrown`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + + // Room only supports messages feature, since by default other features are turned off + val room = DefaultRoom("1234", RoomOptions(), mockRealtimeClient, chatApi, null) + Assert.assertNotNull(room) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // Access presence throws exception + var exception = assertThrows(AblyException::class.java) { + room.presence + } + Assert.assertEquals("Presence is not enabled for this room", exception.errorInfo.message) + Assert.assertEquals(40_000, exception.errorInfo.code) + Assert.assertEquals(400, exception.errorInfo.statusCode) + + // Access reactions throws exception + exception = assertThrows(AblyException::class.java) { + room.reactions + } + Assert.assertEquals("Reactions are not enabled for this room", exception.errorInfo.message) + Assert.assertEquals(40_000, exception.errorInfo.code) + Assert.assertEquals(400, exception.errorInfo.statusCode) + + // Access presence throws exception + exception = assertThrows(AblyException::class.java) { + room.typing + } + Assert.assertEquals("Typing is not enabled for this room", exception.errorInfo.message) + Assert.assertEquals(40_000, exception.errorInfo.code) + Assert.assertEquals(400, exception.errorInfo.statusCode) + + // Access presence throws exception + exception = assertThrows(AblyException::class.java) { + room.occupancy + } + Assert.assertEquals("Occupancy is not enabled for this room", exception.errorInfo.message) + Assert.assertEquals(40_000, exception.errorInfo.code) + Assert.assertEquals(400, exception.errorInfo.statusCode) + + // room with all features + val roomWithAllFeatures = DefaultRoom("1234", RoomOptions.default, mockRealtimeClient, chatApi, null) + Assert.assertNotNull(roomWithAllFeatures.presence) + Assert.assertNotNull(roomWithAllFeatures.reactions) + Assert.assertNotNull(roomWithAllFeatures.typing) + Assert.assertNotNull(roomWithAllFeatures.occupancy) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt b/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt new file mode 100644 index 0000000..d9777ff --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt @@ -0,0 +1,3 @@ +package com.ably.chat.room + +class RoomGetTest diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomReleaseTest.kt b/chat-android/src/test/java/com/ably/chat/room/RoomReleaseTest.kt new file mode 100644 index 0000000..a97a222 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomReleaseTest.kt @@ -0,0 +1,3 @@ +package com.ably.chat.room + +class RoomReleaseTest diff --git a/chat-android/src/test/java/com/ably/chat/room/AttachTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/AttachTest.kt similarity index 95% rename from chat-android/src/test/java/com/ably/chat/room/AttachTest.kt rename to chat-android/src/test/java/com/ably/chat/room/lifecycle/AttachTest.kt index ba98d81..6a18689 100644 --- a/chat-android/src/test/java/com/ably/chat/room/AttachTest.kt +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/AttachTest.kt @@ -1,8 +1,8 @@ -package com.ably.chat.room +package com.ably.chat.room.lifecycle +import com.ably.chat.ContributesToRoomLifecycle import com.ably.chat.DefaultRoomLifecycle import com.ably.chat.ErrorCodes -import com.ably.chat.ResolvedContributor import com.ably.chat.RoomLifecycleManager import com.ably.chat.RoomStatus import com.ably.chat.RoomStatusChange @@ -91,7 +91,7 @@ class AttachTest { @Test fun `(CHA-RL1d) Attach op should wait for existing operation as per (CHA-RL7)`() = runTest { val statusLifecycle = spyk() - Assert.assertEquals(RoomStatus.Initializing, statusLifecycle.status) + Assert.assertEquals(RoomStatus.Initialized, statusLifecycle.status) // CHA-RS3 val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks())) @@ -188,13 +188,13 @@ class AttachTest { val contributorErrors = mutableListOf() for (contributor in contributors) { every { - contributor.contributor.discontinuityDetected(capture(contributorErrors)) + contributor.discontinuityDetected(capture(contributorErrors)) } returns Unit } Assert.assertEquals(5, contributors.size) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors), recordPrivateCalls = true) { - val pendingDiscontinuityEvents = mutableMapOf().apply { + val pendingDiscontinuityEvents = mutableMapOf().apply { for (contributor in contributors) { put(contributor, ErrorInfo("${contributor.channel.name} error", 500)) } @@ -214,7 +214,7 @@ class AttachTest { // CHA-RL1g2 verify(exactly = 1) { for (contributor in contributors) { - contributor.contributor.discontinuityDetected(any()) + contributor.discontinuityDetected(any()) } } Assert.assertEquals(5, contributorErrors.size) @@ -307,10 +307,10 @@ class AttachTest { val contributors = createRoomFeatureMocks("1234") val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors), recordPrivateCalls = true) - val resolvedContributor = slot() + val capturedContributors = slot() // Behaviour for CHA-RL5 will be tested as a part of sub spec for the same - coEvery { roomLifecycle["doRetry"](capture(resolvedContributor)) } coAnswers { + coEvery { roomLifecycle["doRetry"](capture(capturedContributors)) } coAnswers { delay(1000) } @@ -323,9 +323,9 @@ class AttachTest { assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for doRetry to finish coVerify(exactly = 1) { - roomLifecycle["doRetry"](resolvedContributor.captured) + roomLifecycle["doRetry"](capturedContributors.captured) } - Assert.assertEquals("reactions", resolvedContributor.captured.contributor.featureName) + Assert.assertEquals("reactions", capturedContributors.captured.featureName) } @Test @@ -365,7 +365,7 @@ class AttachTest { } coVerify(exactly = 1) { - roomLifecycle["doChannelWindDown"](any()) + roomLifecycle["doChannelWindDown"](any()) } Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) @@ -417,7 +417,7 @@ class AttachTest { // Channel detach success on 6th call coVerify(exactly = 6) { - roomLifecycle["doChannelWindDown"](any()) + roomLifecycle["doChannelWindDown"](any()) } Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) diff --git a/chat-android/src/test/java/com/ably/chat/room/DetachTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt similarity index 97% rename from chat-android/src/test/java/com/ably/chat/room/DetachTest.kt rename to chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt index aadf63e..673986e 100644 --- a/chat-android/src/test/java/com/ably/chat/room/DetachTest.kt +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt @@ -1,9 +1,9 @@ -package com.ably.chat.room +package com.ably.chat.room.lifecycle +import com.ably.chat.ContributesToRoomLifecycle import com.ably.chat.DefaultRoomLifecycle import com.ably.chat.ErrorCodes import com.ably.chat.HttpStatusCodes -import com.ably.chat.ResolvedContributor import com.ably.chat.RoomLifecycleManager import com.ably.chat.RoomStatus import com.ably.chat.RoomStatusChange @@ -161,7 +161,7 @@ class DetachTest { @Test fun `(CHA-RL2i) Detach op should wait for existing operation as per (CHA-RL7)`() = runTest { val statusLifecycle = spyk() - Assert.assertEquals(RoomStatus.Initializing, statusLifecycle.status) + Assert.assertEquals(RoomStatus.Initialized, statusLifecycle.status) // CHA-RS3 val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks())) @@ -250,7 +250,7 @@ class DetachTest { // First fail for typing, second fail for reactions, third is a success coVerify(exactly = 3) { - roomLifecycle["doChannelWindDown"](any()) + roomLifecycle["doChannelWindDown"](any()) } assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } } @@ -338,7 +338,7 @@ class DetachTest { // Channel detach success on 6th call coVerify(exactly = 6) { - roomLifecycle["doChannelWindDown"](any()) + roomLifecycle["doChannelWindDown"](any()) } } } diff --git a/chat-android/src/test/java/com/ably/chat/room/PrecedenceTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt similarity index 94% rename from chat-android/src/test/java/com/ably/chat/room/PrecedenceTest.kt rename to chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt index 1632da2..f7df70c 100644 --- a/chat-android/src/test/java/com/ably/chat/room/PrecedenceTest.kt +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt @@ -1,8 +1,8 @@ -package com.ably.chat.room +package com.ably.chat.room.lifecycle +import com.ably.chat.ContributesToRoomLifecycle import com.ably.chat.DefaultRoomAttachmentResult import com.ably.chat.DefaultRoomLifecycle -import com.ably.chat.ResolvedContributor import com.ably.chat.RoomLifecycleManager import com.ably.chat.RoomStatus import com.ably.chat.RoomStatusChange @@ -51,7 +51,7 @@ class PrecedenceTest { val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors), recordPrivateCalls = true) // Internal operation - coEvery { roomLifecycle["doRetry"](any()) } coAnswers { + coEvery { roomLifecycle["doRetry"](any()) } coAnswers { delay(200) statusLifecycle.setStatus(RoomStatus.Suspended) statusLifecycle.setStatus(RoomStatus.Failed) @@ -105,7 +105,7 @@ class PrecedenceTest { Assert.assertEquals(RoomStatus.Released, roomStatusChanges[5].current) verify { - roomLifecycle["doRetry"](any()) + roomLifecycle["doRetry"](any()) roomLifecycle invokeNoArgs "doAttach" roomLifecycle invokeNoArgs "releaseChannels" } diff --git a/chat-android/src/test/java/com/ably/chat/room/ReleaseTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt similarity index 94% rename from chat-android/src/test/java/com/ably/chat/room/ReleaseTest.kt rename to chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt index 8a120cf..b80ad72 100644 --- a/chat-android/src/test/java/com/ably/chat/room/ReleaseTest.kt +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt @@ -1,4 +1,4 @@ -package com.ably.chat.room +package com.ably.chat.room.lifecycle import com.ably.chat.DefaultRoomLifecycle import com.ably.chat.RoomLifecycleManager @@ -90,7 +90,7 @@ class ReleaseTest { // TODO - need more clarity regarding test case as per https://github.com/ably/ably-chat-js/issues/399 // TODO - There might be a need to rephrase the spec statement val statusLifecycle = spyk() - Assert.assertEquals(RoomStatus.Initializing, statusLifecycle.status) + Assert.assertEquals(RoomStatus.Initialized, statusLifecycle.status) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks())) @@ -137,7 +137,9 @@ class ReleaseTest { @Test fun `(CHA-RL3l) Release op should transition room into RELEASING state, transient timeouts should be cleared`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } val roomStatusChanges = mutableListOf() statusLifecycle.onChange { roomStatusChanges.add(it) @@ -159,7 +161,9 @@ class ReleaseTest { @Test fun `(CHA-RL3d) Release op should detach each contributor channel sequentially and room should be considered RELEASED`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) val capturedChannels = mutableListOf() @@ -190,7 +194,9 @@ class ReleaseTest { @Test fun `(CHA-RL3e) If a one of the contributors is in failed state, release op continues for other contributors`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) val capturedChannels = mutableListOf() @@ -222,7 +228,10 @@ class ReleaseTest { @Test fun `(CHA-RL3f) If a one of the contributors fails to detach, release op continues for all contributors after 250ms delay`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } + val roomEvents = mutableListOf() statusLifecycle.onChange { roomEvents.add(it) @@ -259,7 +268,9 @@ class ReleaseTest { @Test fun `(CHA-RL3g) Release op continues till all contributors enters either DETACHED or FAILED state`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) var failDetachTimes = 5 @@ -303,7 +314,9 @@ class ReleaseTest { @Test fun `(CHA-RL3h) Upon channel release, underlying Realtime Channels are released from the core SDK prevent leakage`() = runTest { - val statusLifecycle = spyk() + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) coEvery { any().detachCoroutine() } coAnswers { @@ -316,7 +329,7 @@ class ReleaseTest { val releasedChannels = mutableListOf() for (contributor in contributors) { - every { contributor.contributor.release() } answers { + every { contributor.release() } answers { releasedChannels.add(contributor.channel) } } @@ -340,15 +353,17 @@ class ReleaseTest { for (contributor in contributors) { verify(exactly = 1) { - contributor.contributor.release() + contributor.release() } } } @Test fun `(CHA-RL3k) Release op should wait for existing operation as per (CHA-RL7)`() = runTest { - val statusLifecycle = spyk() - Assert.assertEquals(RoomStatus.Initializing, statusLifecycle.status) + val statusLifecycle = spyk().apply { + setStatus(RoomStatus.Attached) + } + val roomEvents = mutableListOf() statusLifecycle.onChange { diff --git a/chat-android/src/test/java/com/ably/chat/room/RetryTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt similarity index 94% rename from chat-android/src/test/java/com/ably/chat/room/RetryTest.kt rename to chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt index f9ce4fe..fdc7b42 100644 --- a/chat-android/src/test/java/com/ably/chat/room/RetryTest.kt +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt @@ -1,4 +1,4 @@ -package com.ably.chat.room +package com.ably.chat.room.lifecycle import com.ably.chat.DefaultRoomLifecycle import com.ably.chat.HttpStatusCodes @@ -51,7 +51,7 @@ class RetryTest { val contributors = createRoomFeatureMocks() Assert.assertEquals(5, contributors.size) - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } messagesContributor.channel.setState(ChannelState.attached) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors)) @@ -86,7 +86,7 @@ class RetryTest { val contributors = createRoomFeatureMocks() - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } messagesContributor.channel.setState(ChannelState.attached) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors)) @@ -116,7 +116,7 @@ class RetryTest { val contributors = createRoomFeatureMocks() - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } messagesContributor.channel.setState(ChannelState.attached) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors)) @@ -137,7 +137,7 @@ class RetryTest { coJustRun { any().detachCoroutine() } val contributors = createRoomFeatureMocks() - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } every { messagesContributor.channel.once(eq(ChannelState.attached), any()) @@ -175,7 +175,7 @@ class RetryTest { coJustRun { any().detachCoroutine() } val contributors = createRoomFeatureMocks() - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } messagesContributor.channel.setState(ChannelState.failed) messagesContributor.channel.reason = ErrorInfo("Failed channel messages", HttpStatusCodes.InternalServerError) @@ -208,7 +208,7 @@ class RetryTest { val contributors = createRoomFeatureMocks() Assert.assertEquals(5, contributors.size) - val messagesContributor = contributors.first { it.contributor.featureName == "messages" } + val messagesContributor = contributors.first { it.featureName == "messages" } messagesContributor.channel.setState(ChannelState.attached) val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors)) diff --git a/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt b/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt index 86482c9..949d653 100644 --- a/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt +++ b/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt @@ -2,13 +2,13 @@ package com.ably.utils import com.ably.chat.AtomicCoroutineScope import com.ably.chat.ChatApi +import com.ably.chat.ContributesToRoomLifecycle import com.ably.chat.DefaultMessages import com.ably.chat.DefaultOccupancy import com.ably.chat.DefaultPresence import com.ably.chat.DefaultRoomReactions import com.ably.chat.DefaultTyping import com.ably.chat.LifecycleOperationPrecedence -import com.ably.chat.ResolvedContributor import com.ably.chat.RoomLifecycleManager import com.ably.chat.getPrivateField import com.ably.chat.invokePrivateSuspendMethod @@ -22,10 +22,10 @@ import io.ably.lib.realtime.Channel as AblyRealtimeChannel fun RoomLifecycleManager.atomicCoroutineScope(): AtomicCoroutineScope = getPrivateField("atomicCoroutineScope") -suspend fun RoomLifecycleManager.retry(exceptContributor: ResolvedContributor) = +suspend fun RoomLifecycleManager.retry(exceptContributor: ContributesToRoomLifecycle) = invokePrivateSuspendMethod("doRetry", exceptContributor) -suspend fun RoomLifecycleManager.atomicRetry(exceptContributor: ResolvedContributor) { +suspend fun RoomLifecycleManager.atomicRetry(exceptContributor: ContributesToRoomLifecycle) { atomicCoroutineScope().async(LifecycleOperationPrecedence.Internal.priority) { retry(exceptContributor) }.await() @@ -36,12 +36,17 @@ fun AblyRealtimeChannel.setState(state: ChannelState, errorInfo: ErrorInfo? = nu this.reason = errorInfo } -fun createRoomFeatureMocks(roomId: String = "1234"): List { - val realtimeClient = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false })) +fun createMockRealtimeClient(): AblyRealtime = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false })) + +fun createRoomFeatureMocks(roomId: String = "1234"): List { + val realtimeClient = createMockRealtimeClient() val chatApi = mockk(relaxed = true) val messagesContributor = spyk(DefaultMessages(roomId, realtimeClient.channels, chatApi), recordPrivateCalls = true) - val presenceContributor = spyk(DefaultPresence(messagesContributor), recordPrivateCalls = true) + val presenceContributor = spyk( + DefaultPresence("client1", messagesContributor.channel, messagesContributor.channel.presence), + recordPrivateCalls = true, + ) val occupancyContributor = spyk(DefaultOccupancy(messagesContributor), recordPrivateCalls = true) val typingContributor = spyk(DefaultTyping(roomId, realtimeClient), recordPrivateCalls = true) val reactionsContributor = spyk(DefaultRoomReactions(roomId, "client1", realtimeClient.channels), recordPrivateCalls = true) diff --git a/example/src/main/java/com/ably/chat/example/MainActivity.kt b/example/src/main/java/com/ably/chat/example/MainActivity.kt index 4a851c0..19afd48 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -16,10 +16,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -38,6 +44,7 @@ import com.ably.chat.Message import com.ably.chat.RealtimeClient import com.ably.chat.SendMessageParams import com.ably.chat.SendReactionParams +import com.ably.chat.example.ui.PresencePopup import com.ably.chat.example.ui.theme.AblyChatExampleTheme import io.ably.lib.types.ClientOptions import java.util.UUID @@ -62,17 +69,40 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { AblyChatExampleTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Chat( - chatClient, - modifier = Modifier.padding(innerPadding), - ) - } + App(chatClient) } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun App(chatClient: ChatClient) { + var showPopup by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Chat") }, + actions = { + IconButton(onClick = { showPopup = true }) { + Icon(Icons.Default.Person, contentDescription = "Show members") + } + }, + ) + }, + ) { innerPadding -> + Chat( + chatClient, + modifier = Modifier.padding(innerPadding), + ) + if (showPopup) { + PresencePopup(chatClient, onDismiss = { showPopup = false }) + } + } +} + @SuppressWarnings("LongMethod") @Composable fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { @@ -83,8 +113,7 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() var receivedReactions by remember { mutableStateOf>(listOf()) } - val roomId = "my-room" - val room = chatClient.rooms.get(roomId) + val room = chatClient.rooms.get(Settings.ROOM_ID) DisposableEffect(Unit) { coroutineScope.launch { @@ -130,7 +159,9 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { verticalArrangement = Arrangement.SpaceBetween, ) { LazyColumn( - modifier = Modifier.weight(1f).padding(16.dp), + modifier = Modifier + .weight(1f) + .padding(16.dp), userScrollEnabled = true, state = listState, ) { diff --git a/example/src/main/java/com/ably/chat/example/Settings.kt b/example/src/main/java/com/ably/chat/example/Settings.kt new file mode 100644 index 0000000..e169402 --- /dev/null +++ b/example/src/main/java/com/ably/chat/example/Settings.kt @@ -0,0 +1,5 @@ +package com.ably.chat.example + +object Settings { + const val ROOM_ID = "my-room" +} diff --git a/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt new file mode 100644 index 0000000..cfc52c2 --- /dev/null +++ b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt @@ -0,0 +1,109 @@ +package com.ably.chat.example.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import com.ably.chat.ChatClient +import com.ably.chat.PresenceMember +import com.ably.chat.Subscription +import com.ably.chat.example.Settings +import com.google.gson.JsonObject +import kotlinx.coroutines.launch + +@SuppressWarnings("LongMethod") +@Composable +fun PresencePopup(chatClient: ChatClient, onDismiss: () -> Unit) { + var members by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + val presence = chatClient.rooms.get(Settings.ROOM_ID).presence + + DisposableEffect(Unit) { + var subscription: Subscription? = null + + coroutineScope.launch { + members = presence.get() + subscription = presence.subscribe { + coroutineScope.launch { + members = presence.get() + } + } + } + + onDispose { + subscription?.unsubscribe() + } + } + + Popup( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.padding(16.dp), + shape = MaterialTheme.shapes.medium, + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth(), + ) { + Text("Chat Members", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(8.dp)) + members.forEach { member -> + BasicText("${member.clientId} - (${(member.data as? JsonObject)?.get("status")?.asString})") + Spacer(modifier = Modifier.height(4.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + coroutineScope.launch { + presence.enter( + JsonObject().apply { + addProperty("status", "online") + }, + ) + } + }) { + Text("Join") + } + Button(onClick = { + coroutineScope.launch { + presence.enter( + JsonObject().apply { + addProperty("status", "away") + }, + ) + } + }) { + Text("Appear away") + } + Button(onClick = { + coroutineScope.launch { + presence.leave() + } + }) { + Text("Leave") + } + Button(onClick = onDismiss) { + Text("Close") + } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 83ab262..b1691eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ gson = "2.11.0" mockk = "1.13.13" coroutine = "1.9.0" build-config = "5.4.0" +ktor = "3.0.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -50,9 +51,15 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } + +[bundles] +ktor-client = ["ktor-client-core", "ktor-client-cio"] + [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"} -android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-kotlin = { id = "org.jetbrains.kotlin.android", version = "2.0.21" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }