diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index 8890d600..c9231f1f 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 a86dd8ef..2242babb 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 ee5567ac..fdac08bd 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,15 +243,13 @@ 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 init { channelStateListener = ChannelStateListener { - if (!it.resumed) updateChannelSerialsAfterDiscontinuity() + if (it.current == ChannelState.attached && !it.resumed) updateChannelSerialsAfterDiscontinuity() } channel.on(channelStateListener) } 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 65fd7e94..975b231c 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 e3363c4a..da905b9c 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 4930fc75..dd7c06ff 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -7,6 +7,7 @@ import io.ably.lib.util.Log.LogHandler import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob /** * Represents a chat room. @@ -105,17 +106,12 @@ interface Room { * Detaches from the room to stop receiving events in realtime. */ suspend fun detach() - - /** - * Releases the room, underlying channels are removed from the core SDK to prevent leakage. - */ - suspend fun release() } internal class DefaultRoom( override val roomId: String, override val options: RoomOptions, - realtimeClient: RealtimeClient, + private val realtimeClient: RealtimeClient, chatApi: ChatApi, val logger: LogHandler?, ) : Room { @@ -128,7 +124,10 @@ internal class DefaultRoom( * preventing concurrency issues. Every operation within Room must be performed through this scope. */ private val roomScope = - CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(roomId)) + CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(roomId) + SupervisorJob()) + + private val clientId: String + get() = realtimeClient.auth.clientId override val messages = DefaultMessages( roomId = roomId, @@ -136,75 +135,117 @@ internal class DefaultRoom( chatApi = chatApi, ) - override val presence = DefaultPresence( - messages = messages, - ) + private var _presence: Presence? = null + override val presence: Presence + get() { + if (_presence == null) { // CHA-RC2b + 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) { // CHA-RC2b + 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) { // CHA-RC2b + 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) { // CHA-RC2b + 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() // CHA-RC2a + + 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() + /** + * Releases the room, underlying channels are removed from the core SDK to prevent leakage. + * This is an internal method and only called from Rooms interface implementation. + */ + internal suspend fun 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 260c1400..2eaefc06 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 @@ -117,11 +105,8 @@ class DefaultRoomAttachmentResult : RoomAttachmentResult { override val exception: AblyException get() { val errorInfo = errorField - ?: ErrorInfo("unknown error in attach", HttpStatusCodes.InternalServerError, ErrorCodes.RoomLifecycleError.errorCode) - throwable?.let { - return AblyException.fromErrorInfo(throwable, errorInfo) - } - return AblyException.fromErrorInfo(errorInfo) + ?: lifeCycleErrorInfo("unknown error in attach", ErrorCodes.RoomLifecycleError) + return lifeCycleException(errorInfo, throwable) } } @@ -131,21 +116,11 @@ class DefaultRoomAttachmentResult : RoomAttachmentResult { */ class RoomLifecycleManager( private val roomScope: CoroutineScope, - lifecycle: DefaultRoomLifecycle, - contributors: List, + private val statusLifecycle: DefaultRoomLifecycle, + private val contributors: List, logger: LogHandler? = null, ) { - /** - * The status of the room. - */ - private var _statusLifecycle: DefaultRoomLifecycle = lifecycle - - /** - * The features that contribute to the room status. - */ - private var _contributors: List = contributors - /** * Logger for RoomLifeCycleManager */ @@ -164,7 +139,7 @@ class RoomLifecycleManager( * It is used to prevent the room status from being changed by individual channel state changes and ignore * underlying channel events until we reach a consistent state. */ - private var _operationInProgress = false + private var operationInProgress = false /** * A map of pending discontinuity events. @@ -172,26 +147,19 @@ class RoomLifecycleManager( * When a discontinuity happens due to a failed resume, we don't want to surface that until the room is consistently * attached again. This map allows us to queue up discontinuity events until we're ready to process them. */ - private val _pendingDiscontinuityEvents: DiscontinuityEventMap = mutableMapOf() + private val pendingDiscontinuityEvents: DiscontinuityEventMap = mutableMapOf() /** * A map of contributors to whether their first attach has completed. * * Used to control whether we should trigger discontinuity events. */ - private val _firstAttachesCompleted = mutableMapOf() - - /** - * Are we in the process of releasing the room? - * This property along with related impl. might be removed based on https://github.com/ably/ably-chat-js/issues/399 - * Spec: CHA-RL3c - */ - private var _releaseInProgress = false + private val firstAttachesCompleted = mutableMapOf() /** * Retry duration in milliseconds, used by internal doRetry and runDownChannelsOnFailedAttach methods */ - private val _retryDurationInMs: Long = 250 + private val retryDurationInMs: Long = 250 init { // TODO - [CHA-RL4] set up room monitoring here @@ -221,23 +189,23 @@ class RoomLifecycleManager( * @returns Returns when the room is attached, or the room enters a failed state. * Spec: CHA-RL5 */ - @SuppressWarnings("CognitiveComplexMethod") - private suspend fun doRetry(contributor: ResolvedContributor) { + @Suppress("CognitiveComplexMethod", "ThrowsCount") + 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) { // CHA-RL5c - If in doing the wind down, we've entered failed state, then it's game over anyway - if (this._statusLifecycle.status === RoomStatus.Failed) { + if (this.statusLifecycle.status === RoomStatus.Failed) { throw result.exceptionOrNull() ?: IllegalStateException("room is in a failed state") } - delay(_retryDurationInMs) + delay(retryDurationInMs) result = kotlin.runCatching { doChannelWindDown(contributor) } } // A helper that allows us to retry the attach operation val doAttachWithRetry: suspend () -> Unit = { coroutineScope { - _statusLifecycle.setStatus(RoomStatus.Attaching) + statusLifecycle.setStatus(RoomStatus.Attaching) val attachmentResult = doAttach() // CHA-RL5c - If we're in failed, then we should wind down all the channels, eventually - but we're done here @@ -251,17 +219,12 @@ class RoomLifecycleManager( // If we're in suspended, then we should wait for the channel to reattach, but wait for it to do so if (attachmentResult.status === RoomStatus.Suspended) { val failedFeature = attachmentResult.failedFeature - if (failedFeature == null) { - AblyException.fromErrorInfo( - ErrorInfo( - "no failed feature in doRetry", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomLifecycleError.errorCode, - ), + ?: throw lifeCycleException( + "no failed feature in doRetry", + ErrorCodes.RoomLifecycleError, ) - } // No need to catch errors, rather they should propagate to caller method - return@coroutineScope doRetry(failedFeature as ResolvedContributor) + return@coroutineScope doRetry(failedFeature) } // We attached, huzzah! } @@ -275,12 +238,12 @@ class RoomLifecycleManager( // CHA-RL5d - Otherwise, wait for our suspended contributor channel to re-attach and try again try { listenToChannelAttachOrFailure(contributor) - delay(_retryDurationInMs) // Let other channels get into ATTACHING state + delay(retryDurationInMs) // Let other channels get into ATTACHING state // Attach successful return doAttachWithRetry() } catch (ex: AblyException) { // CHA-RL5c - Channel attach failed - _statusLifecycle.setStatus(RoomStatus.Failed, ex.errorInfo) + statusLifecycle.setStatus(RoomStatus.Failed, ex.errorInfo) throw ex } } @@ -288,7 +251,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) { @@ -305,13 +270,11 @@ class RoomLifecycleManager( // CHA-RL5e val resumeWithExceptionIfFailed = { reason: ErrorInfo? -> if (continuation.isActive) { - val exception = AblyException.fromErrorInfo( - reason - ?: ErrorInfo( - "unknown error in _doRetry", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomLifecycleError.errorCode, - ), + val exception = lifeCycleException( + reason ?: lifeCycleErrorInfo( + "unknown error in doRetry", + ErrorCodes.RoomLifecycleError, + ), ) continuation.resumeWithException(exception) } @@ -333,34 +296,31 @@ class RoomLifecycleManager( * If a channel enters the failed state, we reject and then begin to wind down the other channels. * Spec: CHA-RL1 */ - @SuppressWarnings("ThrowsCount") + @Suppress("ThrowsCount") internal suspend fun attach() { val deferredAttach = atomicCoroutineScope.async(LifecycleOperationPrecedence.AttachOrDetach.priority) { // CHA-RL1d - when (_statusLifecycle.status) { - RoomStatus.Attached -> return@async // CHA-RL1a - RoomStatus.Releasing -> // CHA-RL1b - throw AblyException.fromErrorInfo( - ErrorInfo( - "unable to attach room; room is releasing", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomIsReleasing.errorCode, - ), - ) - RoomStatus.Released -> // CHA-RL1c - throw AblyException.fromErrorInfo( - ErrorInfo( - "unable to attach room; room is released", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomIsReleased.errorCode, - ), - ) - else -> {} + if (statusLifecycle.status == RoomStatus.Attached) { // CHA-RL1a + return@async + } + + if (statusLifecycle.status == RoomStatus.Releasing) { // CHA-RL1b + throw lifeCycleException( + "unable to attach room; room is releasing", + ErrorCodes.RoomIsReleasing, + ) + } + + if (statusLifecycle.status == RoomStatus.Released) { // CHA-RL1c + throw lifeCycleException( + "unable to attach room; room is released", + ErrorCodes.RoomIsReleased, + ) } // At this point, we force the room status to be attaching clearAllTransientDetachTimeouts() - _operationInProgress = true - _statusLifecycle.setStatus(RoomStatus.Attaching) // CHA-RL1e + operationInProgress = true + statusLifecycle.setStatus(RoomStatus.Attaching) // CHA-RL1e val attachResult = doAttach() @@ -376,12 +336,9 @@ class RoomLifecycleManager( // CHA-RL1h1, CHA-RL1h2 - If we're in suspended, then this attach should fail, but we'll retry after a short delay async if (attachResult.status === RoomStatus.Suspended) { if (attachResult.failedFeature == null) { - AblyException.fromErrorInfo( - ErrorInfo( - "no failed feature in attach", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomLifecycleError.errorCode, - ), + throw lifeCycleException( + "no failed feature in attach", + ErrorCodes.RoomLifecycleError, ) } attachResult.failedFeature?.let { @@ -407,17 +364,16 @@ class RoomLifecycleManager( */ private suspend fun doAttach(): RoomAttachmentResult { val attachResult = DefaultRoomAttachmentResult() - for (feature in _contributors) { // CHA-RL1f - attach each feature sequentially + for (feature in contributors) { // CHA-RL1f - attach each feature sequentially try { feature.channel.attachCoroutine() - _firstAttachesCompleted[feature] = true + firstAttachesCompleted[feature] = true } catch (ex: Throwable) { // CHA-RL1h - handle channel attach failure attachResult.throwable = ex attachResult.failedFeatureField = feature - attachResult.errorField = ErrorInfo( - "failed to attach ${feature.contributor.featureName} feature${feature.channel.errorMessage}", - HttpStatusCodes.InternalServerError, - feature.contributor.attachmentErrorCode.errorCode, + attachResult.errorField = lifeCycleErrorInfo( + "failed to attach ${feature.featureName} feature${feature.channel.errorMessage}", + feature.attachmentErrorCode, ) // The current feature should be in one of two states, it will be either suspended or failed @@ -428,30 +384,29 @@ class RoomLifecycleManager( ChannelState.failed -> attachResult.statusField = RoomStatus.Failed else -> { attachResult.statusField = RoomStatus.Failed - attachResult.errorField = ErrorInfo( + attachResult.errorField = lifeCycleErrorInfo( "unexpected channel state in doAttach ${feature.channel.state}${feature.channel.errorMessage}", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomLifecycleError.errorCode, + ErrorCodes.RoomLifecycleError, ) } } // Regardless of whether we're suspended or failed, run-down the other channels // The wind-down procedure will take Precedence over any user-driven actions - _statusLifecycle.setStatus(attachResult) + statusLifecycle.setStatus(attachResult) return attachResult } } // CHA-RL1g, We successfully attached all the channels - set our status to attached, start listening changes in channel status - this._statusLifecycle.setStatus(attachResult) - this._operationInProgress = false + this.statusLifecycle.setStatus(attachResult) + this.operationInProgress = false // Iterate the pending discontinuity events and trigger them - for ((contributor, error) in _pendingDiscontinuityEvents) { - contributor.contributor.discontinuityDetected(error) + for ((contributor, error) in pendingDiscontinuityEvents) { + contributor.discontinuityDetected(error) } - _pendingDiscontinuityEvents.clear() + pendingDiscontinuityEvents.clear() return attachResult } @@ -467,7 +422,7 @@ class RoomLifecycleManager( while (channelWindDown.isFailure) { // CHA-RL1h6 - repeat until all channels are detached // Something went wrong during the wind down. After a short delay, to give others a turn, we should run down // again until we reach a suitable conclusion. - delay(_retryDurationInMs) + delay(retryDurationInMs) channelWindDown = kotlin.runCatching { doChannelWindDown() } } } @@ -480,20 +435,20 @@ class RoomLifecycleManager( * @returns Success/Failure when all channels are detached or at least one of them fails. * */ - @SuppressWarnings("CognitiveComplexMethod", "ComplexCondition") - private suspend fun doChannelWindDown(except: ResolvedContributor? = null) = coroutineScope { - _contributors.map { contributor: ResolvedContributor -> + @Suppress("CognitiveComplexMethod", "ComplexCondition") + 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 - if (contributor.channel === except?.channel && _statusLifecycle.status !== RoomStatus.Failed) { + if (contributor.channel === except?.channel && statusLifecycle.status !== RoomStatus.Failed) { return@async } // If the room's already in the failed state, or it's releasing, we should not detach a failed channel if (( - _statusLifecycle.status === RoomStatus.Failed || - _statusLifecycle.status === RoomStatus.Releasing || - _statusLifecycle.status === RoomStatus.Released + statusLifecycle.status === RoomStatus.Failed || + statusLifecycle.status === RoomStatus.Releasing || + statusLifecycle.status === RoomStatus.Released ) && contributor.channel.state === ChannelState.failed ) { @@ -506,21 +461,20 @@ class RoomLifecycleManager( // CHA-RL2h2 - If the contributor is in a failed state and we're not ignoring failed states, we should fail the room if ( contributor.channel.state === ChannelState.failed && - _statusLifecycle.status !== RoomStatus.Failed && - _statusLifecycle.status !== RoomStatus.Releasing && - _statusLifecycle.status !== RoomStatus.Released + statusLifecycle.status !== RoomStatus.Failed && + statusLifecycle.status !== RoomStatus.Releasing && + statusLifecycle.status !== RoomStatus.Released ) { - val contributorError = ErrorInfo( - "failed to detach ${contributor.contributor.featureName} feature${contributor.channel.errorMessage}", - HttpStatusCodes.InternalServerError, - contributor.contributor.detachmentErrorCode.errorCode, + val contributorError = lifeCycleErrorInfo( + "failed to detach ${contributor.featureName} feature${contributor.channel.errorMessage}", + contributor.detachmentErrorCode, ) - _statusLifecycle.setStatus(RoomStatus.Failed, contributorError) - throw AblyException.fromErrorInfo(throwable, contributorError) + statusLifecycle.setStatus(RoomStatus.Failed, contributorError) + throw lifeCycleException(contributorError, throwable) } // CHA-RL2h3 - We throw an error so that the promise rejects - throw AblyException.fromErrorInfo(throwable, ErrorInfo("detach failure, retry", -1, -1)) + throw lifeCycleException(ErrorInfo("detach failure, retry", -1, -1), throwable) } } }.awaitAll() @@ -536,46 +490,37 @@ class RoomLifecycleManager( internal suspend fun detach() { val deferredDetach = atomicCoroutineScope.async(LifecycleOperationPrecedence.AttachOrDetach.priority) { // CHA-RL2i // CHA-RL2a - If we're already detached, this is a no-op - if (_statusLifecycle.status === RoomStatus.Detached) { + if (statusLifecycle.status === RoomStatus.Detached) { return@async } // CHA-RL2c - If the room is released, we can't detach - if (_statusLifecycle.status === RoomStatus.Released) { - throw AblyException.fromErrorInfo( - ErrorInfo( - "unable to detach room; room is released", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomIsReleased.errorCode, - ), + if (statusLifecycle.status === RoomStatus.Released) { + throw lifeCycleException( + "unable to detach room; room is released", + ErrorCodes.RoomIsReleased, ) } // CHA-RL2b - If the room is releasing, we can't detach - if (_statusLifecycle.status === RoomStatus.Releasing) { - throw AblyException.fromErrorInfo( - ErrorInfo( - "unable to detach room; room is releasing", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomIsReleasing.errorCode, - ), + if (statusLifecycle.status === RoomStatus.Releasing) { + throw lifeCycleException( + "unable to detach room; room is releasing", + ErrorCodes.RoomIsReleasing, ) } // CHA-RL2d - If we're in failed, we should not attempt to detach - if (_statusLifecycle.status === RoomStatus.Failed) { - throw AblyException.fromErrorInfo( - ErrorInfo( - "unable to detach room; room has failed", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomInFailedState.errorCode, - ), + if (statusLifecycle.status === RoomStatus.Failed) { + throw lifeCycleException( + "unable to detach room; room has failed", + ErrorCodes.RoomInFailedState, ) } // CHA-RL2e - We force the room status to be detaching - _operationInProgress = true + operationInProgress = true clearAllTransientDetachTimeouts() - _statusLifecycle.setStatus(RoomStatus.Detaching) + statusLifecycle.setStatus(RoomStatus.Detaching) // CHA-RL2f - We now perform an all-channel wind down. // We keep trying until we reach a suitable conclusion. @@ -597,24 +542,21 @@ class RoomLifecycleManager( if (err is AblyException && err.errorInfo?.code != -1 && firstContributorFailedError == null) { firstContributorFailedError = err // CHA-RL2h1- First failed contributor error is captured } - delay(_retryDurationInMs) + delay(retryDurationInMs) channelWindDown = kotlin.runCatching { doChannelWindDown() } } // CHA-RL2g - If we aren't in the failed state, then we're detached - if (_statusLifecycle.status !== RoomStatus.Failed) { - _statusLifecycle.setStatus(RoomStatus.Detached) + if (statusLifecycle.status !== RoomStatus.Failed) { + statusLifecycle.setStatus(RoomStatus.Detached) return } // CHA-RL2h1 - If we're in the failed state, then we need to throw the error throw firstContributorFailedError - ?: AblyException.fromErrorInfo( - ErrorInfo( - "unknown error in _doDetach", - HttpStatusCodes.InternalServerError, - ErrorCodes.RoomLifecycleError.errorCode, - ), + ?: lifeCycleException( + "unknown error in doDetach", + ErrorCodes.RoomLifecycleError, ) } @@ -630,30 +572,22 @@ class RoomLifecycleManager( internal suspend fun release() { val deferredRelease = atomicCoroutineScope.async(LifecycleOperationPrecedence.Release.priority) { // CHA-RL3k // CHA-RL3a - If we're already released, this is a no-op - if (_statusLifecycle.status === RoomStatus.Released) { + if (statusLifecycle.status === RoomStatus.Released) { return@async } // CHA-RL3b, CHA-RL3j - If we're already detached or initialized, then we can transition to released immediately - if (_statusLifecycle.status === RoomStatus.Detached || - _statusLifecycle.status === RoomStatus.Initialized + if (statusLifecycle.status === RoomStatus.Detached || + statusLifecycle.status === RoomStatus.Initialized ) { - _statusLifecycle.setStatus(RoomStatus.Released) + statusLifecycle.setStatus(RoomStatus.Released) return@async } - - // CHA-RL3c - If we're in the process of releasing, we should wait for it to complete - // This might be removed in the future based on https://github.com/ably/ably-chat-js/issues/399 - if (_releaseInProgress) { - return@async listenToRoomRelease() - } - // CHA-RL3l - We force the room status to be releasing. // Any transient disconnect timeouts shall be cleared. clearAllTransientDetachTimeouts() - _operationInProgress = true - _releaseInProgress = true - _statusLifecycle.setStatus(RoomStatus.Releasing) + operationInProgress = true + statusLifecycle.setStatus(RoomStatus.Releasing) // CHA-RL3f - Do the release until it completes return@async releaseChannels() @@ -661,23 +595,6 @@ class RoomLifecycleManager( deferredRelease.await() } - private suspend fun listenToRoomRelease() = suspendCancellableCoroutine { continuation -> - _statusLifecycle.onChangeOnce { - if (it.current == RoomStatus.Released) { - continuation.resume(Unit) - } else { - val err = AblyException.fromErrorInfo( - ErrorInfo( - "failed to release room; existing attempt failed${it.errorMessage}", - HttpStatusCodes.InternalServerError, - ErrorCodes.PreviousOperationFailed.errorCode, - ), - ) - continuation.resumeWithException(err) - } - } - } - /** * Releases the room by detaching all channels. If the release operation fails, we wait * a short period and then try again. @@ -687,7 +604,7 @@ class RoomLifecycleManager( var contributorsReleased = kotlin.runCatching { doRelease() } while (contributorsReleased.isFailure) { // Wait a short period and then try again - delay(_retryDurationInMs) + delay(retryDurationInMs) contributorsReleased = kotlin.runCatching { doRelease() } } } @@ -699,7 +616,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) { @@ -719,10 +636,9 @@ class RoomLifecycleManager( }.awaitAll() // CHA-RL3h - underlying Realtime Channels are released from the core SDK prevent leakage - _contributors.forEach { - it.contributor.release() + contributors.forEach { + it.release() } - _releaseInProgress = false - _statusLifecycle.setStatus(RoomStatus.Released) // CHA-RL3g + 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 ae2af907..76c6e139 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,15 @@ object RoomReactionsOptions * Represents the occupancy options for a chat room. */ object OccupancyOptions + +/** + * Throws AblyException for invalid room configuration. + * Spec: CHA-RC2a + */ +fun RoomOptions.validateRoomOptions() { + typing?.let { + if (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 72f6ab59..863cbb6f 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 b2e79b7e..69b58c5e 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. @@ -154,12 +149,6 @@ interface NewRoomStatus { * @internal */ interface InternalRoomLifecycle : RoomLifecycle { - /** - * Registers a listener that will be called once when the room status changes. - * @param listener The function to call when the status changes. - */ - fun onChangeOnce(listener: RoomLifecycle.Listener) - /** * Sets the status of the room. * @@ -182,7 +171,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 @@ -204,10 +194,6 @@ class DefaultRoomLifecycle(private val logger: LogHandler? = null) : InternalRoo externalEmitter.off() } - override fun onChangeOnce(listener: RoomLifecycle.Listener) { - internalEmitter.once(listener) - } - override fun setStatus(params: NewRoomStatus) { setStatus(params.status, params.error) } diff --git a/chat-android/src/main/java/com/ably/chat/Rooms.kt b/chat-android/src/main/java/com/ably/chat/Rooms.kt index a316e0e7..0cb6fdb1 100644 --- a/chat-android/src/main/java/com/ably/chat/Rooms.kt +++ b/chat-android/src/main/java/com/ably/chat/Rooms.kt @@ -1,7 +1,11 @@ package com.ably.chat -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch /** * Manages the lifecycle of chat rooms. @@ -9,6 +13,7 @@ import io.ably.lib.types.ErrorInfo interface Rooms { /** * Get the client options used to create the Chat instance. + * @returns ClientOptions */ val clientOptions: ClientOptions @@ -19,12 +24,19 @@ interface Rooms { * * Always call `release(roomId)` after the Room object is no longer needed. * + * If a call to `get` is made for a room that is currently being released, then the promise will resolve only when + * the release operation is complete. + * + * If a call to `get` is made, followed by a subsequent call to `release` before the promise resolves, then the + * promise will reject with an error. + * * @param roomId The ID of the room. * @param options The options for the room. * @throws {@link ErrorInfo} if a room with the same ID but different options already exists. * @returns Room A new or existing Room object. + * Spec: CHA-RC1f */ - fun get(roomId: String, options: RoomOptions = RoomOptions()): Room + suspend fun get(roomId: String, options: RoomOptions = RoomOptions()): Room /** * Release the Room object if it exists. This method only releases the reference @@ -34,7 +46,10 @@ interface Rooms { * After calling this function, the room object is no-longer usable. If you wish to get the room object again, * you must call {@link Rooms.get}. * + * Calling this function will abort any in-progress `get` calls for the same room. + * * @param roomId The ID of the room. + * Spec: CHA-RC1g, CHA-RC1g1 */ suspend fun release(roomId: String) } @@ -47,32 +62,108 @@ internal class DefaultRooms( private val chatApi: ChatApi, override val clientOptions: ClientOptions, ) : Rooms { + + /** + * All operations for DefaultRooms should be executed under sequentialScope to avoid concurrency issues. + * This makes sure all members/properties accessed by one coroutine at a time. + */ + private val sequentialScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) + private val roomIdToRoom: MutableMap = mutableMapOf() + private val roomGetDeferred: MutableMap> = mutableMapOf() + private val roomReleaseDeferred: MutableMap> = mutableMapOf() - override fun get(roomId: String, options: RoomOptions): Room { - return synchronized(this) { - val room = roomIdToRoom.getOrPut(roomId) { - DefaultRoom( - roomId = roomId, - options = options, - realtimeClient = realtimeClient, - chatApi = chatApi, - logger = clientOptions.logHandler, - ) + override suspend fun get(roomId: String, options: RoomOptions): Room { + return sequentialScope.async { + val existingRoom = getReleasedOrExistingRoom(roomId) + existingRoom?.let { + if (options != existingRoom.options) { // CHA-RC1f1 + throw ablyException("room already exists with different options", ErrorCodes.BadRequest) + } + return@async existingRoom // CHA-RC1f2 } + // CHA-RC1f3 + val newRoom = makeRoom(roomId, options) + roomIdToRoom[roomId] = newRoom + return@async newRoom + }.await() + } - if (room.options != options) { - throw AblyException.fromErrorInfo( - ErrorInfo("Room already exists with different options", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest.errorCode), + override suspend fun release(roomId: String) { + sequentialScope.launch { + // CHA-RC1g4 - Previous Room Get in progress, cancel all of them + roomGetDeferred[roomId]?.let { + val exception = ablyException( + "room released before get operation could complete", + ErrorCodes.RoomReleasedBeforeOperationCompleted, ) + it.completeExceptionally(exception) } - room - } + // CHA-RC1g2, CHA-RC1g3 + val existingRoom = roomIdToRoom[roomId] + existingRoom?.let { + if (roomReleaseDeferred.containsKey(roomId)) { + roomReleaseDeferred[roomId]?.await() + } else { + val roomReleaseDeferred = CompletableDeferred() + this@DefaultRooms.roomReleaseDeferred[roomId] = roomReleaseDeferred + existingRoom.release() // CHA-RC1g5 + roomReleaseDeferred.complete(Unit) + } + } + roomReleaseDeferred.remove(roomId) + roomIdToRoom.remove(roomId) + }.join() } - override suspend fun release(roomId: String) { - val room = roomIdToRoom.remove(roomId) - room?.release() + /** + * @returns null for released room or non-null existing active room (not in releasing/released state) + * Spec: CHA-RC1f4, CHA-RC1f5, CHA-RC1f6, CHA-RC1g4 + */ + @Suppress("ReturnCount") + private suspend fun getReleasedOrExistingRoom(roomId: String): Room? { + // Previous Room Get in progress, because room release in progress + // So await on same deferred and return null + roomGetDeferred[roomId]?.let { + it.await() + return null + } + + val existingRoom = roomIdToRoom[roomId] + existingRoom?.let { + val roomReleaseInProgress = roomReleaseDeferred[roomId] + roomReleaseInProgress?.let { + val roomGetDeferred = CompletableDeferred() + this.roomGetDeferred[roomId] = roomGetDeferred + roomGetDeferred.invokeOnCompletion { + it?.let { + this.roomGetDeferred.remove(roomId) + } + } + roomReleaseInProgress.await() + if (roomGetDeferred.isActive) { + roomGetDeferred.complete(Unit) + } else { + roomGetDeferred.await() + } + this.roomGetDeferred.remove(roomId) + return null + } + return existingRoom + } + return null } + + /** + * makes a new room object + * + * @param roomId The ID of the room. + * @param options The options for the room. + * + * @returns DefaultRoom A new room object. + * Spec: CHA-RC1f3 + */ + private fun makeRoom(roomId: String, options: RoomOptions): DefaultRoom = + DefaultRoom(roomId, options, realtimeClient, chatApi, null) } 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 42dd2e94..87380249 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 434a33dd..6f2a67a5 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,37 @@ internal class DeferredValue { return result } } + +fun lifeCycleErrorInfo( + errorMessage: String, + errorCode: ErrorCodes, + statusCode: Int = HttpStatusCodes.InternalServerError, +) = ErrorInfo(errorMessage, statusCode, errorCode.errorCode) + +fun lifeCycleException( + errorMessage: String, + errorCode: ErrorCodes, + statusCode: Int = HttpStatusCodes.InternalServerError, + cause: Throwable? = null, +): AblyException = ablyException(errorMessage, errorCode, statusCode, cause) + +fun lifeCycleException(errorInfo: ErrorInfo, cause: Throwable? = null) = ablyException(errorInfo, cause) + +fun ablyException(errorInfo: ErrorInfo, cause: Throwable? = null): AblyException { + cause?.let { + return AblyException.fromErrorInfo(cause, errorInfo) + } + return AblyException.fromErrorInfo(errorInfo) +} + +fun ablyException( + errorMessage: String, + errorCode: ErrorCodes, + statusCode: Int = HttpStatusCodes.BadRequest, + cause: Throwable? = null, +): AblyException { + cause?.let { + return AblyException.fromErrorInfo(cause, ErrorInfo(errorMessage, statusCode, errorCode.errorCode)) + } + return AblyException.fromErrorInfo(ErrorInfo(errorMessage, statusCode, errorCode.errorCode)) +} diff --git a/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt b/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt index c2f71b80..3fc94c5b 100644 --- a/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt +++ b/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt @@ -1,7 +1,6 @@ package com.ably.chat import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo import java.util.concurrent.LinkedBlockingQueue import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -40,7 +39,7 @@ class AtomicCoroutineScopeTest { val atomicCoroutineScope = AtomicCoroutineScope() val deferredResult1 = atomicCoroutineScope.async { delay(2000) - throw AblyException.fromErrorInfo(ErrorInfo("Error performing operation", 400)) + throw clientError("Error performing operation") } val deferredResult2 = atomicCoroutineScope.async { delay(2000) 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 00000000..c43f421d --- /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 00000000..b0ab50b2 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/Sandbox.kt @@ -0,0 +1,87 @@ +package com.ably.chat + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.ConnectionEvent +import io.ably.lib.realtime.ConnectionState +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 +import kotlinx.coroutines.CompletableDeferred + +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 + }, + ) + +internal suspend fun Sandbox.getConnectedChatClient(): DefaultChatClient { + val realtime = createSandboxRealtime(apiKey) + realtime.ensureConnected() + return DefaultChatClient(realtime, ClientOptions()) +} + +private suspend fun AblyRealtime.ensureConnected() { + if (this.connection.state == ConnectionState.connected) { + return + } + val connectedDeferred = CompletableDeferred() + this.connection.on { + if (it.event == ConnectionEvent.connected) { + connectedDeferred.complete(Unit) + } else if (it.event != ConnectionEvent.connecting) { + connectedDeferred.completeExceptionally(serverError("ably connection failed")) + this.connection.off() + this.close() + } + } + connectedDeferred.await() +} + +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 00000000..577dfdb7 --- /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/TestUtils.kt b/chat-android/src/test/java/com/ably/chat/TestUtils.kt index f6f73ee2..f89d9c97 100644 --- a/chat-android/src/test/java/com/ably/chat/TestUtils.kt +++ b/chat-android/src/test/java/com/ably/chat/TestUtils.kt @@ -86,3 +86,7 @@ suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: A it.invoke(this, *args, cont) } } + +fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCodes.BadRequest, HttpStatusCodes.BadRequest) + +fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCodes.InternalError, HttpStatusCodes.InternalServerError) 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 00000000..43fca7ee --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt @@ -0,0 +1,91 @@ +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 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 typing 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 occupancy 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 00000000..b9308521 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt @@ -0,0 +1,217 @@ +package com.ably.chat.room + +import com.ably.chat.ChatApi +import com.ably.chat.ClientOptions +import com.ably.chat.DefaultRoom +import com.ably.chat.DefaultRooms +import com.ably.chat.PresenceOptions +import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatus +import com.ably.chat.TypingOptions +import com.ably.chat.assertWaiter +import io.ably.lib.types.AblyException +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Spec: CHA-RC1f + */ +class RoomGetTest { + @Test + fun `(CHA-RC1f) Requesting a room from the Chat Client return instance of a room with the provided id and options`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()) + val room = rooms.get("1234", RoomOptions()) + Assert.assertNotNull(room) + Assert.assertEquals("1234", room.roomId) + Assert.assertEquals(RoomOptions(), room.options) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RC1f1) If the room id already exists, and newly requested with different options, then ErrorInfo with code 40000 is thrown`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + // Create room with id "1234" + val room = rooms.get("1234", RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room, rooms.RoomIdToRoom["1234"]) + + // Throws exception for requesting room for different roomOptions + val exception = assertThrows(AblyException::class.java) { + runBlocking { + rooms.get("1234", RoomOptions(typing = TypingOptions())) + } + } + Assert.assertNotNull(exception) + Assert.assertEquals(40_000, exception.errorInfo.code) + Assert.assertEquals("room already exists with different options", exception.errorInfo.message) + } + + @Test + fun `(CHA-RC1f2) If the room id already exists, and newly requested with same options, then returns same room`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val room1 = rooms.get("1234", RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room1, rooms.RoomIdToRoom["1234"]) + + val room2 = rooms.get("1234", RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room1, room2) + + val room3 = rooms.get("5678", RoomOptions(typing = TypingOptions())) + Assert.assertEquals(2, rooms.RoomIdToRoom.size) + Assert.assertEquals(room3, rooms.RoomIdToRoom["5678"]) + + val room4 = rooms.get("5678", RoomOptions(typing = TypingOptions())) + Assert.assertEquals(2, rooms.RoomIdToRoom.size) + Assert.assertEquals(room3, room4) + + val room5 = rooms.get( + "7890", + RoomOptions( + typing = TypingOptions(timeoutMs = 1500), + presence = PresenceOptions( + enter = true, + subscribe = false, + ), + ), + ) + Assert.assertEquals(3, rooms.RoomIdToRoom.size) + Assert.assertEquals(room5, rooms.RoomIdToRoom["7890"]) + + val room6 = rooms.get( + "7890", + RoomOptions( + typing = TypingOptions(timeoutMs = 1500), + presence = PresenceOptions( + enter = true, + subscribe = false, + ), + ), + ) + Assert.assertEquals(3, rooms.RoomIdToRoom.size) + Assert.assertEquals(room5, room6) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RC1f3) If no CHA-RC1g release operation is in progress, a new room instance shall be created, and added to the room map`() = runTest { + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + val roomId = "1234" + + // No release op. in progress + Assert.assertEquals(0, rooms.RoomReleaseDeferred.size) + Assert.assertNull(rooms.RoomReleaseDeferred[roomId]) + + // Creates a new room and adds to the room map + val room = rooms.get("1234", RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room, rooms.RoomIdToRoom[roomId]) + } + + @Suppress("MaximumLineLength", "LongMethod") + @Test + fun `(CHA-RC1f4, CHA-RC1f5) If CHA-RC1g release operation is in progress, new instance should not be returned until release operation completes`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null), + recordPrivateCalls = true, + ) + + val roomReleased = Channel() + coEvery { + defaultRoom.release() + } coAnswers { + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Releasing) + roomReleased.receive() + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Released) + roomReleased.close() + } + + every { + rooms["makeRoom"](any(), any()) + } answers { + var room = defaultRoom + if (roomReleased.isClosedForSend) { + room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null) + } + room + } + + // Creates original room and adds to the room map + val originalRoom = rooms.get(roomId, RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(originalRoom, rooms.RoomIdToRoom[roomId]) + + // Release the room in separate coroutine, keep it in progress + val invocationOrder = mutableListOf() + val roomReleaseDeferred = launch { rooms.release(roomId) } + roomReleaseDeferred.invokeOnCompletion { + invocationOrder.add("room.released") + } + + // Get the same room in separate coroutine, it should wait for release op + val roomGetDeferred = async { rooms.get(roomId) } + roomGetDeferred.invokeOnCompletion { + invocationOrder.add("room.get") + } + + // Room is in releasing state, hence RoomReleaseDeferred contain deferred for given roomId + assertWaiter { originalRoom.status == RoomStatus.Releasing } + Assert.assertEquals(1, rooms.RoomReleaseDeferred.size) + Assert.assertNotNull(rooms.RoomReleaseDeferred[roomId]) + + // CHA-RC1f5 - Room Get is in waiting state, for room to get released + assertWaiter { rooms.RoomGetDeferred.size == 1 } + Assert.assertEquals(1, rooms.RoomGetDeferred.size) + Assert.assertNotNull(rooms.RoomGetDeferred[roomId]) + + // Release the room, room release deferred gets empty + roomReleased.send(Unit) + assertWaiter { originalRoom.status == RoomStatus.Released } + assertWaiter { rooms.RoomReleaseDeferred.isEmpty() } + Assert.assertNull(rooms.RoomReleaseDeferred[roomId]) + + // Room Get in waiting state gets cleared, so it's map for the same is cleared + assertWaiter { rooms.RoomGetDeferred.isEmpty() } + Assert.assertEquals(0, rooms.RoomGetDeferred.size) + Assert.assertNull(rooms.RoomGetDeferred[roomId]) + + val newRoom = roomGetDeferred.await() + roomReleaseDeferred.join() + + // Check order of invocations + Assert.assertEquals(listOf("room.released", "room.get"), invocationOrder) + + // Check if new room is returned + Assert.assertNotSame(newRoom, originalRoom) + + verify(exactly = 2) { + rooms["makeRoom"](any(), any()) + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt new file mode 100644 index 00000000..ba10b7d9 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt @@ -0,0 +1,103 @@ +package com.ably.chat.room + +import com.ably.chat.ChatClient +import com.ably.chat.Room +import com.ably.chat.RoomStatus +import com.ably.chat.RoomStatusChange +import com.ably.chat.Sandbox +import com.ably.chat.assertWaiter +import com.ably.chat.createSandboxChatClient +import com.ably.chat.getConnectedChatClient +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class RoomIntegrationTest { + private lateinit var sandbox: Sandbox + + @Before + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + + private suspend fun validateAllOps(room: Room, chatClient: ChatClient) { + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // Listen for underlying state changes + val stateChanges = mutableListOf() + room.onStatusChange { + stateChanges.add(it) + } + + // Perform attach operation + room.attach() + Assert.assertEquals(RoomStatus.Attached, room.status) + + // Perform detach operation + room.detach() + Assert.assertEquals(RoomStatus.Detached, room.status) + + // Perform release operation + chatClient.rooms.release(room.roomId) + Assert.assertEquals(RoomStatus.Released, room.status) + + assertWaiter { room.LifecycleManager.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(5, stateChanges.size) + Assert.assertEquals(RoomStatus.Attaching, stateChanges[0].current) + Assert.assertEquals(RoomStatus.Attached, stateChanges[1].current) + Assert.assertEquals(RoomStatus.Detaching, stateChanges[2].current) + Assert.assertEquals(RoomStatus.Detached, stateChanges[3].current) + Assert.assertEquals(RoomStatus.Released, stateChanges[4].current) + } + + private suspend fun validateAttachAndRelease(room: Room, chatClient: ChatClient) { + // Listen for underlying state changes + val stateChanges = mutableListOf() + room.onStatusChange { + stateChanges.add(it) + } + + // Perform attach operation + room.attach() + Assert.assertEquals(RoomStatus.Attached, room.status) + + // Perform release operation + chatClient.rooms.release(room.roomId) + Assert.assertEquals(RoomStatus.Released, room.status) + + assertWaiter { room.LifecycleManager.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(4, stateChanges.size) + Assert.assertEquals(RoomStatus.Attaching, stateChanges[0].current) + Assert.assertEquals(RoomStatus.Attached, stateChanges[1].current) + Assert.assertEquals(RoomStatus.Releasing, stateChanges[2].current) + Assert.assertEquals(RoomStatus.Released, stateChanges[3].current) + } + + @Test + fun `should be able to Attach, Detach and Release Room`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val room1 = chatClient.rooms.get(UUID.randomUUID().toString()) + validateAllOps(room1, chatClient) + + val room2 = chatClient.rooms.get(UUID.randomUUID().toString()) + validateAttachAndRelease(room2, chatClient) + + chatClient.realtime.close() + } + + @Test + fun `should be able to Attach, Detach and Release Room for connected client`() = runTest { + val chatClient = sandbox.getConnectedChatClient() + val room1 = chatClient.rooms.get(UUID.randomUUID().toString()) + validateAllOps(room1, chatClient) + + val room2 = chatClient.rooms.get(UUID.randomUUID().toString()) + validateAttachAndRelease(room2, chatClient) + + chatClient.realtime.close() + } +} 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 00000000..c8a77e51 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomReleaseTest.kt @@ -0,0 +1,281 @@ +package com.ably.chat.room + +import com.ably.chat.ChatApi +import com.ably.chat.ClientOptions +import com.ably.chat.DefaultRoom +import com.ably.chat.DefaultRooms +import com.ably.chat.ErrorCodes +import com.ably.chat.Room +import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatus +import com.ably.chat.RoomStatusChange +import com.ably.chat.assertWaiter +import io.ably.lib.types.AblyException +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RC1g + */ +class RoomReleaseTest { + @Test + fun `(CHA-RC1g) Should be able to release existing room, makes it eligible for garbage collection`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null), + recordPrivateCalls = true, + ) + coJustRun { defaultRoom.release() } + + every { rooms["makeRoom"](any(), any()) } returns defaultRoom + + // Creates original room and adds to the room map + val room = rooms.get(roomId, RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room, rooms.RoomIdToRoom[roomId]) + + // Release the room + rooms.release(roomId) + + Assert.assertEquals(0, rooms.RoomIdToRoom.size) + } + + @Test + fun `(CHA-RC1g1, CHA-RC1g5) Release operation only returns after channel goes into Released state`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null), + recordPrivateCalls = true, + ) + + val roomStateChanges = mutableListOf() + defaultRoom.onStatusChange { + roomStateChanges.add(it) + } + + coEvery { + defaultRoom.release() + } coAnswers { + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Releasing) + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Released) + } + + every { rooms["makeRoom"](any(), any()) } returns defaultRoom + + // Creates original room and adds to the room map + val room = rooms.get(roomId, RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room, rooms.RoomIdToRoom[roomId]) + + // Release the room + rooms.release(roomId) + + // CHA-RC1g5 - Room is removed after release operation + Assert.assertEquals(0, rooms.RoomIdToRoom.size) + + Assert.assertEquals(2, roomStateChanges.size) + Assert.assertEquals(RoomStatus.Releasing, roomStateChanges[0].current) + Assert.assertEquals(RoomStatus.Released, roomStateChanges[1].current) + } + + @Test + fun `(CHA-RC1g2) If the room does not exist in the room map, and no release operation is in progress, there is no-op`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + // No room exists + Assert.assertEquals(0, rooms.RoomIdToRoom.size) + Assert.assertEquals(0, rooms.RoomReleaseDeferred.size) + Assert.assertEquals(0, rooms.RoomGetDeferred.size) + + // Release the room + rooms.release(roomId) + + Assert.assertEquals(0, rooms.RoomIdToRoom.size) + Assert.assertEquals(0, rooms.RoomReleaseDeferred.size) + Assert.assertEquals(0, rooms.RoomGetDeferred.size) + } + + @Test + fun `(CHA-RC1g3) If the release operation is already in progress, then the associated deferred will be resolved`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null), + recordPrivateCalls = true, + ) + every { rooms["makeRoom"](any(), any()) } returns defaultRoom + + val roomReleased = Channel() + coEvery { + defaultRoom.release() + } coAnswers { + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Releasing) + roomReleased.receive() + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Released) + } + + // Creates a room and adds to the room map + val room = rooms.get(roomId, RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(room, rooms.RoomIdToRoom[roomId]) + + // Release the room in separate coroutine, keep it in progress + val releasedDeferredList = mutableListOf>() + + repeat(1000) { + val roomReleaseDeferred = async(Dispatchers.IO) { + rooms.release(roomId) + } + releasedDeferredList.add(roomReleaseDeferred) + } + + // Wait for room to get into releasing state + assertWaiter { room.status == RoomStatus.Releasing } + Assert.assertEquals(1, rooms.RoomReleaseDeferred.size) + Assert.assertNotNull(rooms.RoomReleaseDeferred[roomId]) + + // Release the room, room release deferred gets empty + roomReleased.send(Unit) + releasedDeferredList.awaitAll() + Assert.assertEquals(RoomStatus.Released, room.status) + + Assert.assertTrue(rooms.RoomReleaseDeferred.isEmpty()) + Assert.assertTrue(rooms.RoomIdToRoom.isEmpty()) + + coVerify(exactly = 1) { + defaultRoom.release() + } + } + + @Suppress("MaximumLineLength", "LongMethod") + @Test + fun `(CHA-RC1g4, CHA-RC1f6) Pending room get operation waiting for room release should be cancelled and deferred associated with previous release operation will be resolved`() = runTest { + val roomId = "1234" + val mockRealtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val rooms = spyk(DefaultRooms(mockRealtimeClient, chatApi, ClientOptions()), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null), + recordPrivateCalls = true, + ) + + val roomReleased = Channel() + coEvery { + defaultRoom.release() + } coAnswers { + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Releasing) + roomReleased.receive() + defaultRoom.StatusLifecycle.setStatus(RoomStatus.Released) + roomReleased.close() + } + + every { + rooms["makeRoom"](any(), any()) + } answers { + var room = defaultRoom + if (roomReleased.isClosedForSend) { + room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, null) + } + room + } + + // Creates original room and adds to the room map + val originalRoom = rooms.get(roomId, RoomOptions()) + Assert.assertEquals(1, rooms.RoomIdToRoom.size) + Assert.assertEquals(originalRoom, rooms.RoomIdToRoom[roomId]) + + // Release the room in separate coroutine, keep it in progress + launch { rooms.release(roomId) } + + // Room is in releasing state, hence RoomReleaseDeferred contain deferred for given roomId + assertWaiter { originalRoom.status == RoomStatus.Releasing } + Assert.assertEquals(1, rooms.RoomReleaseDeferred.size) + Assert.assertNotNull(rooms.RoomReleaseDeferred[roomId]) + + // Call roomGet Dispatchers.IO scope, it should wait for release op + val roomGetDeferredList = mutableListOf>() + repeat(100) { + val roomGetDeferred = async(Dispatchers.IO + SupervisorJob()) { + rooms.get(roomId) + } + roomGetDeferredList.add(roomGetDeferred) + } + // CHA-RC1f5 - Room Get is in waiting state, for room to get released + assertWaiter { rooms.RoomGetDeferred.size == 1 } + Assert.assertNotNull(rooms.RoomGetDeferred[roomId]) + + // Call the release again, so that all pending roomGet gets cancelled + val roomReleaseDeferred = launch { rooms.release(roomId) } + + // All RoomGetDeferred got cancelled due to room release. + assertWaiter { rooms.RoomGetDeferred.isEmpty() } + + // Call RoomGet after release, so this should return a new room when room is released + val roomGetDeferred = async { rooms.get(roomId) } + + // CHA-RC1f5 - Room Get is in waiting state, for room to get released + assertWaiter { rooms.RoomGetDeferred.size == 1 } + Assert.assertNotNull(rooms.RoomGetDeferred[roomId]) + + // Release the room, room release deferred gets empty + roomReleased.send(Unit) + assertWaiter { originalRoom.status == RoomStatus.Released } + assertWaiter { rooms.RoomReleaseDeferred.isEmpty() } + Assert.assertNull(rooms.RoomReleaseDeferred[roomId]) + + // Room Get in waiting state gets cleared, so it's map for the same is cleared + assertWaiter { rooms.RoomGetDeferred.isEmpty() } + Assert.assertEquals(0, rooms.RoomGetDeferred.size) + Assert.assertNull(rooms.RoomGetDeferred[roomId]) + + roomReleaseDeferred.join() + + val newRoom = roomGetDeferred.await() + Assert.assertNotSame(newRoom, originalRoom) // Check new room created + + for (deferred in roomGetDeferredList) { + val result = kotlin.runCatching { deferred.await() } + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + Assert.assertEquals(ErrorCodes.RoomReleasedBeforeOperationCompleted.errorCode, exception.errorInfo.code) + Assert.assertEquals("room released before get operation could complete", exception.errorInfo.message) + } + + verify(exactly = 2) { + rooms["makeRoom"](any(), any()) + } + coVerify(exactly = 1) { + defaultRoom.release() + } + } +} diff --git a/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt similarity index 57% rename from chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt rename to chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt index 86482c92..d86b56ae 100644 --- a/chat-android/src/test/java/com/ably/utils/RoomTestHelpers.kt +++ b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt @@ -1,15 +1,18 @@ -package com.ably.utils +package com.ably.chat.room 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.DefaultRoomLifecycle import com.ably.chat.DefaultRoomReactions import com.ably.chat.DefaultTyping import com.ably.chat.LifecycleOperationPrecedence -import com.ably.chat.ResolvedContributor +import com.ably.chat.Room import com.ably.chat.RoomLifecycleManager +import com.ably.chat.Rooms import com.ably.chat.getPrivateField import com.ably.chat.invokePrivateSuspendMethod import io.ably.lib.realtime.AblyRealtime @@ -18,32 +21,48 @@ import io.ably.lib.types.ClientOptions import io.ably.lib.types.ErrorInfo import io.mockk.mockk import io.mockk.spyk +import kotlinx.coroutines.CompletableDeferred import io.ably.lib.realtime.Channel as AblyRealtimeChannel +fun createMockRealtimeClient(): AblyRealtime = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false })) + +// Rooms mocks +val Rooms.RoomIdToRoom get() = getPrivateField>("roomIdToRoom") +val Rooms.RoomGetDeferred get() = getPrivateField>>("roomGetDeferred") +val Rooms.RoomReleaseDeferred get() = getPrivateField>>("roomReleaseDeferred") + +// Room mocks +val Room.StatusLifecycle get() = getPrivateField("statusLifecycle") +val Room.LifecycleManager get() = getPrivateField("lifecycleManager") + +// RoomLifeCycleManager Mocks 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() } -fun AblyRealtimeChannel.setState(state: ChannelState, errorInfo: ErrorInfo? = null) { - this.state = state - this.reason = errorInfo -} - -fun createRoomFeatureMocks(roomId: String = "1234"): List { - val realtimeClient = 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) return listOf(messagesContributor, presenceContributor, occupancyContributor, typingContributor, reactionsContributor) } + +fun AblyRealtimeChannel.setState(state: ChannelState, errorInfo: ErrorInfo? = null) { + this.state = state + this.reason = errorInfo +} 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 92% 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 1a3844a7..08331127 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,19 +1,21 @@ -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 +import com.ably.chat.ablyException import com.ably.chat.assertWaiter import com.ably.chat.attachCoroutine import com.ably.chat.detachCoroutine +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.createRoomFeatureMocks +import com.ably.chat.room.setState +import com.ably.chat.serverError import com.ably.chat.setPrivateField -import com.ably.utils.atomicCoroutineScope -import com.ably.utils.createRoomFeatureMocks -import com.ably.utils.setState import io.ably.lib.realtime.ChannelState import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo @@ -95,7 +97,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())) @@ -194,18 +196,18 @@ 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)) } } - this.setPrivateField("_pendingDiscontinuityEvents", pendingDiscontinuityEvents) + this.setPrivateField("pendingDiscontinuityEvents", pendingDiscontinuityEvents) } justRun { roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" } @@ -219,7 +221,7 @@ class AttachTest { // CHA-RL1g2 verify(exactly = 1) { for (contributor in contributors) { - contributor.contributor.discontinuityDetected(any()) + contributor.discontinuityDetected(any()) } } Assert.assertEquals(5, contributorErrors.size) @@ -242,7 +244,7 @@ class AttachTest { if ("reactions" in channel.name) { // Throw error for typing contributor, likely to throw because it uses different channel channel.setState(ChannelState.suspended) - throw AblyException.fromErrorInfo(ErrorInfo("error attaching channel ${channel.name}", 500)) + throw serverError("error attaching channel ${channel.name}") } } @@ -273,7 +275,7 @@ class AttachTest { // Throw error for typing contributor, likely to throw because it uses different channel val error = ErrorInfo("error attaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } } @@ -305,17 +307,17 @@ class AttachTest { if ("reactions" in channel.name) { // Throw error for typing contributor, likely to throw because it uses different channel channel.setState(ChannelState.suspended) - throw AblyException.fromErrorInfo(ErrorInfo("error attaching channel ${channel.name}", 500)) + throw serverError("error attaching channel ${channel.name}") } } 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) } @@ -328,9 +330,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 @@ -344,7 +346,7 @@ class AttachTest { // Throw error for typing contributor, likely to throw because it uses different channel val error = ErrorInfo("error attaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } } @@ -370,7 +372,7 @@ class AttachTest { } coVerify(exactly = 1) { - roomLifecycle["doChannelWindDown"](any()) + roomLifecycle["doChannelWindDown"](any()) } Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) @@ -391,7 +393,7 @@ class AttachTest { // Throw error for typing contributor, likely to throw because it uses different channel val error = ErrorInfo("error attaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } } @@ -422,7 +424,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 95% 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 aadf63ef..346a178e 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,17 +1,18 @@ -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 +import com.ably.chat.ablyException import com.ably.chat.assertWaiter import com.ably.chat.detachCoroutine -import com.ably.utils.atomicCoroutineScope -import com.ably.utils.createRoomFeatureMocks -import com.ably.utils.setState +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.createRoomFeatureMocks +import com.ably.chat.room.setState import io.ably.lib.realtime.ChannelState import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo @@ -161,7 +162,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())) @@ -217,13 +218,13 @@ class DetachTest { if ("typing" in channel.name) { // Throw error for typing contributor val error = ErrorInfo("error detaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } if ("reactions" in channel.name) { // Throw error for reactions contributor val error = ErrorInfo("error detaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } } @@ -250,7 +251,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 } } @@ -272,13 +273,13 @@ class DetachTest { if ("typing" in channel.name) { val error = ErrorInfo("error detaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } if ("reactions" in channel.name) { val error = ErrorInfo("error detaching channel ${channel.name}", 500) channel.setState(ChannelState.failed, error) - throw AblyException.fromErrorInfo(error) + throw ablyException(error) } } @@ -338,7 +339,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 92% 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 1632da28..ea4540f4 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,15 +1,15 @@ -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 import com.ably.chat.assertWaiter -import com.ably.utils.atomicCoroutineScope -import com.ably.utils.atomicRetry -import com.ably.utils.createRoomFeatureMocks +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.atomicRetry +import com.ably.chat.room.createRoomFeatureMocks import io.mockk.coEvery import io.mockk.spyk import io.mockk.verify @@ -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 83% 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 8a120cf9..ed08da9f 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 @@ -6,9 +6,9 @@ import com.ably.chat.RoomStatus import com.ably.chat.RoomStatusChange import com.ably.chat.assertWaiter import com.ably.chat.detachCoroutine -import com.ably.utils.atomicCoroutineScope -import com.ably.utils.createRoomFeatureMocks -import com.ably.utils.setState +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.createRoomFeatureMocks +import com.ably.chat.room.setState import io.ably.lib.realtime.ChannelState import io.mockk.coEvery import io.mockk.coVerify @@ -85,59 +85,11 @@ class ReleaseTest { Assert.assertEquals(RoomStatus.Initialized, states[0].previous) } - @Test - fun `(CHA-RL3c) If room is in Releasing status, op should return result of pending release op`() = runTest { - // 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) - - val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks())) - - val roomReleased = Channel() - var callOriginalRelease = false - coEvery { - roomLifecycle.release() - } coAnswers { - roomLifecycle.atomicCoroutineScope().async { - if (callOriginalRelease) { - callOriginal() - } else { - statusLifecycle.setStatus(RoomStatus.Releasing) - roomReleased.receive() - statusLifecycle.setStatus(RoomStatus.Released) - } - } - } - - // Release op started from separate coroutine - launch { roomLifecycle.release() } - assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } - Assert.assertEquals(0, roomLifecycle.atomicCoroutineScope().pendingJobCount) // no queued jobs, release op running - assertWaiter { statusLifecycle.status == RoomStatus.Releasing } - - // Original release op started from separate coroutine - callOriginalRelease = true - val roomReleaseOpDeferred = async { roomLifecycle.release() } - assertWaiter { roomLifecycle.atomicCoroutineScope().pendingJobCount == 1 } // release op queued - Assert.assertEquals(RoomStatus.Releasing, statusLifecycle.status) - - // Finish previous release op, so new Release op can start - roomReleased.send(true) - assertWaiter { statusLifecycle.status == RoomStatus.Released } - - val result = kotlin.runCatching { roomReleaseOpDeferred.await() } - Assert.assertTrue(result.isSuccess) - Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) - - Assert.assertTrue(roomLifecycle.atomicCoroutineScope().finishedProcessing) - - coVerify { roomLifecycle.release() } - } - @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 +111,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 +144,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 +178,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 +218,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 +264,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 +279,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 +303,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 92% 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 f9ce4fee..e156fa34 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 @@ -7,10 +7,10 @@ import com.ably.chat.RoomStatus import com.ably.chat.assertWaiter import com.ably.chat.attachCoroutine import com.ably.chat.detachCoroutine -import com.ably.utils.atomicCoroutineScope -import com.ably.utils.createRoomFeatureMocks -import com.ably.utils.retry -import com.ably.utils.setState +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.createRoomFeatureMocks +import com.ably.chat.room.retry +import com.ably.chat.room.setState import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener import io.ably.lib.types.AblyException @@ -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/example/src/main/java/com/ably/chat/example/MainActivity.kt b/example/src/main/java/com/ably/chat/example/MainActivity.kt index 4a851c05..7220756a 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,10 +44,12 @@ 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 import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking val randomClientId = UUID.randomUUID().toString() @@ -62,18 +70,41 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { AblyChatExampleTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Chat( - chatClient, - modifier = Modifier.padding(innerPadding), - ) - } + App(chatClient) } } } } -@SuppressWarnings("LongMethod") +@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 }) + } + } +} + +@Suppress("LongMethod") @Composable fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { var messageText by remember { mutableStateOf(TextFieldValue("")) } @@ -83,8 +114,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 = runBlocking { chatClient.rooms.get(Settings.ROOM_ID) } DisposableEffect(Unit) { coroutineScope.launch { @@ -130,7 +160,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 00000000..e1694024 --- /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 00000000..0be80704 --- /dev/null +++ b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt @@ -0,0 +1,110 @@ +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 +import kotlinx.coroutines.runBlocking + +@Suppress("LongMethod") +@Composable +fun PresencePopup(chatClient: ChatClient, onDismiss: () -> Unit) { + var members by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + val presence = runBlocking { 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 83ab2622..20739a34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] ably-chat = "0.0.1" -ably = "1.2.44" +ably = "1.2.45" junit = "4.13.2" agp = "8.5.2" detekt = "1.23.6" @@ -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" }