diff --git a/chat-android/src/main/java/com/ably/chat/AtomicCoroutineScope.kt b/chat-android/src/main/java/com/ably/chat/AtomicCoroutineScope.kt new file mode 100644 index 0000000..e977063 --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/AtomicCoroutineScope.kt @@ -0,0 +1,93 @@ +package com.ably.chat + +import java.util.concurrent.PriorityBlockingQueue +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch + +/** + * AtomicCoroutineScope is a thread safe wrapper to run multiple operations mutually exclusive. + * All operations are atomic and run with given priority. + * Accepts scope as a constructor parameter to run operations under the given scope. + * See [Kotlin Dispatchers](https://kt.academy/article/cc-dispatchers) for more information. + */ +class AtomicCoroutineScope(private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default)) { + + private val sequentialScope: CoroutineScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1)) + + private class Job( + private val priority: Int, + val coroutineBlock: suspend CoroutineScope.() -> T, + val deferredResult: CompletableDeferred, + val queuedPriority: Int, + ) : Comparable> { + override fun compareTo(other: Job<*>) = when { + this.priority == other.priority -> this.queuedPriority.compareTo(other.queuedPriority) + else -> this.priority.compareTo(other.priority) + } + } + + // Handles jobs of any type + private val jobs: PriorityBlockingQueue> = PriorityBlockingQueue() // Accessed from both sequentialScope and async method + private var isRunning = false // Only accessed from sequentialScope + private var queueCounter = 0 // Only accessed from synchronized method + + val finishedProcessing: Boolean + get() = jobs.isEmpty() && !isRunning + + val pendingJobCount: Int + get() = jobs.size + + /** + * Defines priority for the operation execution and + * executes given coroutineBlock mutually exclusive under given scope. + */ + @Synchronized + fun async(priority: Int = 0, coroutineBlock: suspend CoroutineScope.() -> T): CompletableDeferred { + val deferredResult = CompletableDeferred() + jobs.add(Job(priority, coroutineBlock, deferredResult, queueCounter++)) + sequentialScope.launch { + if (!isRunning) { + isRunning = true + while (jobs.isNotEmpty()) { + val job = jobs.poll() + job?.let { + safeExecute(it) + } + } + isRunning = false + } + } + return deferredResult + } + + private suspend fun safeExecute(job: Job) { + try { + // Appends coroutineContext to cancel current/pending jobs when AtomicCoroutineScope is cancelled + scope.launch(coroutineContext) { + try { + val result = job.coroutineBlock(this) + job.deferredResult.complete(result) + } catch (t: Throwable) { + job.deferredResult.completeExceptionally(t) + } + }.join() + } catch (t: Throwable) { + job.deferredResult.completeExceptionally(t) + } + } + + /** + * Cancels ongoing and pending operations with given error. + * See [Coroutine cancellation](https://kt.academy/article/cc-cancellation#cancellation-in-a-coroutine-scope) for more information. + */ + @Synchronized + fun cancel(message: String?, cause: Throwable? = null) { + queueCounter = 0 + sequentialScope.coroutineContext.cancelChildren(CancellationException(message, cause)) + } +} diff --git a/chat-android/src/main/java/com/ably/chat/ChatApi.kt b/chat-android/src/main/java/com/ably/chat/ChatApi.kt index a463f79..8ff8c4e 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatApi.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -81,7 +81,7 @@ internal class ChatApi( metadata = params.metadata ?: mapOf(), headers = params.headers ?: mapOf(), ) - } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCodes.InternalServerError)) + } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCode.InternalServerError)) } private fun validateSendMessageParams(params: SendMessageParams) { @@ -90,8 +90,8 @@ internal class ChatApi( throw AblyException.fromErrorInfo( ErrorInfo( "Metadata contains reserved 'ably-chat' key", - HttpStatusCodes.BadRequest, - ErrorCodes.InvalidRequestBody, + HttpStatusCode.BadRequest, + ErrorCode.InvalidRequestBody.code, ), ) } @@ -101,8 +101,8 @@ internal class ChatApi( throw AblyException.fromErrorInfo( ErrorInfo( "Headers contains reserved key with reserved 'ably-chat' prefix", - HttpStatusCodes.BadRequest, - ErrorCodes.InvalidRequestBody, + HttpStatusCode.BadRequest, + ErrorCode.InvalidRequestBody.code, ), ) } @@ -117,7 +117,7 @@ internal class ChatApi( connections = it.requireInt("connections"), presenceMembers = it.requireInt("presenceMembers"), ) - } ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCodes.InternalServerError)) + } ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCode.InternalServerError)) } private suspend fun makeAuthorizedRequest( diff --git a/chat-android/src/main/java/com/ably/chat/Connection.kt b/chat-android/src/main/java/com/ably/chat/Connection.kt index a1378cc..1f346fa 100644 --- a/chat-android/src/main/java/com/ably/chat/Connection.kt +++ b/chat-android/src/main/java/com/ably/chat/Connection.kt @@ -1,5 +1,73 @@ package com.ably.chat +import io.ably.lib.types.ErrorInfo + +/** + * Default timeout for transient states before we attempt handle them as a state change. + */ +const val TRANSIENT_TIMEOUT = 5000 + +/** + * The different states that the connection can be in through its lifecycle. + */ +enum class ConnectionStatus(val stateName: String) { + /** + * A temporary state for when the library is first initialized. + */ + Initialized("initialized"), + + /** + * The library is currently connecting to Ably. + */ + Connecting("connecting"), + + /** + * The library is currently connected to Ably. + */ + Connected("connected"), + + /** + * The library is currently disconnected from Ably, but will attempt to reconnect. + */ + Disconnected("disconnected"), + + /** + * The library is in an extended state of disconnection, but will attempt to reconnect. + */ + Suspended("suspended"), + + /** + * The library is currently disconnected from Ably and will not attempt to reconnect. + */ + Failed("failed"), +} + +/** + * Represents a change in the status of the connection. + */ +data class ConnectionStatusChange( + /** + * The new status of the connection. + */ + val current: ConnectionStatus, + + /** + * The previous status of the connection. + */ + val previous: ConnectionStatus, + + /** + * An error that provides a reason why the connection has + * entered the new status, if applicable. + */ + val error: ErrorInfo?, + + /** + * The time in milliseconds that the client will wait before attempting to reconnect. + */ + val retryIn: Long?, +) + /** * Represents a connection to Ably. */ @@ -8,4 +76,32 @@ interface Connection { * The current status of the connection. */ val status: ConnectionStatus + + /** + * The current error, if any, that caused the connection to enter the current status. + */ + val error: ErrorInfo? + + /** + * Registers a listener that will be called whenever the connection status changes. + * @param listener The function to call when the status changes. + * @returns An object that can be used to unregister the listener. + */ + fun onStatusChange(listener: Listener): Subscription + + /** + * An interface for listening to changes for the connection status + */ + fun interface Listener { + /** + * A function that can be called when the connection status changes. + * @param change The change in status. + */ + fun connectionStatusChanged(change: ConnectionStatusChange) + } + + /** + * Removes all listeners that were added by the `onStatusChange` method. + */ + fun offAllStatusChange() } diff --git a/chat-android/src/main/java/com/ably/chat/ConnectionStatus.kt b/chat-android/src/main/java/com/ably/chat/ConnectionStatus.kt deleted file mode 100644 index 2822f10..0000000 --- a/chat-android/src/main/java/com/ably/chat/ConnectionStatus.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.ably.chat - -import io.ably.lib.types.ErrorInfo - -/** - * Default timeout for transient states before we attempt handle them as a state change. - */ -const val TRANSIENT_TIMEOUT = 5000 - -/** - * Represents a connection to Ably. - */ -interface ConnectionStatus { - /** - * The current status of the connection. - */ - val current: ConnectionLifecycle - - /** - * The current error, if any, that caused the connection to enter the current status. - */ - val error: ErrorInfo? - - /** - * Registers a listener that will be called whenever the connection status changes. - * @param listener The function to call when the status changes. - */ - fun on(listener: Listener): Subscription - - /** - * An interface for listening to changes for the connection status - */ - fun interface Listener { - /** - * A function that can be called when the connection status changes. - * @param change The change in status. - */ - fun connectionStatusChanged(change: ConnectionStatusChange) - } -} - -/** - * The different states that the connection can be in through its lifecycle. - */ -enum class ConnectionLifecycle(val stateName: String) { - /** - * A temporary state for when the library is first initialized. - */ - Initialized("initialized"), - - /** - * The library is currently connecting to Ably. - */ - Connecting("connecting"), - - /** - * The library is currently connected to Ably. - */ - Connected("connected"), - - /** - * The library is currently disconnected from Ably, but will attempt to reconnect. - */ - Disconnected("disconnected"), - - /** - * The library is in an extended state of disconnection, but will attempt to reconnect. - */ - Suspended("suspended"), - - /** - * The library is currently disconnected from Ably and will not attempt to reconnect. - */ - Failed("failed"), -} - -/** - * Represents a change in the status of the connection. - */ -data class ConnectionStatusChange( - /** - * The new status of the connection. - */ - val current: ConnectionLifecycle, - - /** - * The previous status of the connection. - */ - val previous: ConnectionLifecycle, - - /** - * An error that provides a reason why the connection has - * entered the new status, if applicable. - */ - val error: ErrorInfo?, - - /** - * The time in milliseconds that the client will wait before attempting to reconnect. - */ - val retryIn: Long?, -) diff --git a/chat-android/src/main/java/com/ably/chat/Discontinuities.kt b/chat-android/src/main/java/com/ably/chat/Discontinuities.kt new file mode 100644 index 0000000..f9ac6cf --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/Discontinuities.kt @@ -0,0 +1,57 @@ +package com.ably.chat + +import io.ably.lib.types.ErrorInfo +import io.ably.lib.util.EventEmitter +import io.ably.lib.realtime.ChannelBase as AblyRealtimeChannel + +/** + * Represents an object that has a channel and therefore may care about discontinuities. + */ +interface HandlesDiscontinuity { + /** + * A promise of the channel that this object is associated with. The promise + * is resolved when the feature has finished initializing. + */ + val channel: AblyRealtimeChannel + + /** + * Called when a discontinuity is detected on the channel. + * @param reason The error that caused the discontinuity. + */ + fun discontinuityDetected(reason: ErrorInfo?) +} + +/** + * An interface to be implemented by objects that can emit discontinuities to listeners. + */ +interface EmitsDiscontinuities { + /** + * Register a listener to be called when a discontinuity is detected. + * @param listener The listener to be called when a discontinuity is detected. + */ + fun onDiscontinuity(listener: Listener): Subscription + + /** + * An interface for listening when discontinuity happens + */ + fun interface Listener { + /** + * A function that can be called when discontinuity happens. + * @param reason reason for discontinuity + */ + fun discontinuityEmitted(reason: ErrorInfo?) + } +} + +internal class DiscontinuityEmitter(logger: Logger) : EventEmitter() { + private val logger = logger.withContext("DiscontinuityEmitter") + + override fun apply(listener: EmitsDiscontinuities.Listener?, event: String?, vararg args: Any?) { + try { + val reason = args.firstOrNull() as? ErrorInfo? + listener?.discontinuityEmitted(reason) + } catch (t: Throwable) { + logger.error("Unexpected exception calling Discontinuity Listener", t) + } + } +} diff --git a/chat-android/src/main/java/com/ably/chat/EmitsDiscontinuities.kt b/chat-android/src/main/java/com/ably/chat/EmitsDiscontinuities.kt deleted file mode 100644 index 07412f4..0000000 --- a/chat-android/src/main/java/com/ably/chat/EmitsDiscontinuities.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.ably.chat - -import io.ably.lib.types.ErrorInfo - -/** - * An interface to be implemented by objects that can emit discontinuities to listeners. - */ -interface EmitsDiscontinuities { - /** - * Register a listener to be called when a discontinuity is detected. - * @param listener The listener to be called when a discontinuity is detected. - */ - fun onDiscontinuity(listener: Listener): Subscription - - /** - * An interface for listening when discontinuity happens - */ - fun interface Listener { - /** - * A function that can be called when discontinuity happens. - * @param reason reason for discontinuity - */ - fun discontinuityEmitted(reason: ErrorInfo?) - } -} 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 b683aed..b0a00bd 100644 --- a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt +++ b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt @@ -3,111 +3,117 @@ package com.ably.chat /** * Error codes for the Chat SDK. */ -object ErrorCodes { +enum class ErrorCode(val code: Int) { + /** * The messages feature failed to attach. */ - const val MessagesAttachmentFailed = 102_001 + MessagesAttachmentFailed(102_001), /** * The presence feature failed to attach. */ - const val PresenceAttachmentFailed = 102_002 + PresenceAttachmentFailed(102_002), /** * The reactions feature failed to attach. */ - const val ReactionsAttachmentFailed = 102_003 + ReactionsAttachmentFailed(102_003), /** * The occupancy feature failed to attach. */ - const val OccupancyAttachmentFailed = 102_004 + OccupancyAttachmentFailed(102_004), /** * The typing feature failed to attach. */ - const val TypingAttachmentFailed = 102_005 - // 102006 - 102049 reserved for future use for attachment errors + TypingAttachmentFailed(102_005), + // 102_006 - 102_049 reserved for future use for attachment errors /** * The messages feature failed to detach. */ - const val MessagesDetachmentFailed = 102_050 + MessagesDetachmentFailed(102_050), /** * The presence feature failed to detach. */ - const val PresenceDetachmentFailed = 102_051 + PresenceDetachmentFailed(102_051), /** * The reactions feature failed to detach. */ - const val ReactionsDetachmentFailed = 102_052 + ReactionsDetachmentFailed(102_052), /** * The occupancy feature failed to detach. */ - const val OccupancyDetachmentFailed = 102_053 + OccupancyDetachmentFailed(102_053), /** * The typing feature failed to detach. */ - const val TypingDetachmentFailed = 102_054 - // 102055 - 102099 reserved for future use for detachment errors + TypingDetachmentFailed(102_054), + // 102_055 - 102_099 reserved for future use for detachment errors /** * The room has experienced a discontinuity. */ - const val RoomDiscontinuity = 102_100 + RoomDiscontinuity(102_100), // Unable to perform operation; /** * Cannot perform operation because the room is in a failed state. */ - const val RoomInFailedState = 102_101 + RoomInFailedState(102_101), /** * Cannot perform operation because the room is in a releasing state. */ - const val RoomIsReleasing = 102_102 + RoomIsReleasing(102_102), /** * Cannot perform operation because the room is in a released state. */ - const val RoomIsReleased = 102_103 + RoomIsReleased(102_103), + + /** + * Room was released before the operation could complete. + */ + RoomReleasedBeforeOperationCompleted(102_106), /** * Cannot perform operation because the previous operation failed. */ - const val PreviousOperationFailed = 102_104 + PreviousOperationFailed(102_104), /** * An unknown error has happened in the room lifecycle. */ - const val RoomLifecycleError = 102_105 + RoomLifecycleError(102_105), /** * The request cannot be understood */ - const val BadRequest = 40_000 + BadRequest(40_000), /** * Invalid request body */ - const val InvalidRequestBody = 40_001 + InvalidRequestBody(40_001), /** * Internal error */ - const val InternalError = 50_000 + InternalError(50_000), } /** * Http Status Codes */ -object HttpStatusCodes { +object HttpStatusCode { const val BadRequest = 400 diff --git a/chat-android/src/main/java/com/ably/chat/JsonUtils.kt b/chat-android/src/main/java/com/ably/chat/JsonUtils.kt index 2e119b8..c1941d8 100644 --- a/chat-android/src/main/java/com/ably/chat/JsonUtils.kt +++ b/chat-android/src/main/java/com/ably/chat/JsonUtils.kt @@ -22,7 +22,7 @@ internal fun JsonElement.toMap() = buildMap { internal fun JsonElement.requireJsonObject(): JsonObject { if (!isJsonObject) { throw AblyException.fromErrorInfo( - ErrorInfo("Response value expected to be JsonObject, got primitive instead", HttpStatusCodes.InternalServerError), + ErrorInfo("Response value expected to be JsonObject, got primitive instead", HttpStatusCode.InternalServerError), ) } return asJsonObject @@ -34,7 +34,7 @@ internal fun JsonElement.requireString(memberName: String): String { throw AblyException.fromErrorInfo( ErrorInfo( "Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", - HttpStatusCodes.InternalServerError, + HttpStatusCode.InternalServerError, ), ) } @@ -48,7 +48,7 @@ internal fun JsonElement.requireLong(memberName: String): Long { } catch (formatException: NumberFormatException) { throw AblyException.fromErrorInfo( formatException, - ErrorInfo("Required numeric field \"$memberName\" is not a valid long", HttpStatusCodes.InternalServerError), + ErrorInfo("Required numeric field \"$memberName\" is not a valid long", HttpStatusCode.InternalServerError), ) } } @@ -60,7 +60,7 @@ internal fun JsonElement.requireInt(memberName: String): Int { } catch (formatException: NumberFormatException) { throw AblyException.fromErrorInfo( formatException, - ErrorInfo("Required numeric field \"$memberName\" is not a valid int", HttpStatusCodes.InternalServerError), + ErrorInfo("Required numeric field \"$memberName\" is not a valid int", HttpStatusCode.InternalServerError), ) } } @@ -71,7 +71,7 @@ internal fun JsonElement.requireJsonPrimitive(memberName: String): JsonPrimitive throw AblyException.fromErrorInfo( ErrorInfo( "Value for \"$memberName\" field expected to be JsonPrimitive, got object instead", - HttpStatusCodes.InternalServerError, + HttpStatusCode.InternalServerError, ), ) } @@ -80,5 +80,5 @@ internal fun JsonElement.requireJsonPrimitive(memberName: String): JsonPrimitive internal fun JsonElement.requireField(memberName: String): JsonElement = requireJsonObject().get(memberName) ?: throw AblyException.fromErrorInfo( - ErrorInfo("Required field \"$memberName\" is missing", HttpStatusCodes.InternalServerError), + ErrorInfo("Required field \"$memberName\" is missing", HttpStatusCode.InternalServerError), ) 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 cf9c411..90f9b93 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -5,13 +5,13 @@ package com.ably.chat import com.ably.chat.QueryOptions.MessageOrder.NewestFirst import com.google.gson.JsonObject import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo +import io.ably.lib.realtime.Channel as AblyRealtimeChannel -typealias PubSubMessageListener = Channel.MessageListener +typealias PubSubMessageListener = AblyRealtimeChannel.MessageListener typealias PubSubMessage = io.ably.lib.types.Message /** @@ -26,7 +26,7 @@ interface Messages : EmitsDiscontinuities { * * @returns the realtime channel */ - val channel: Channel + val channel: AblyRealtimeChannel /** * Subscribe to new messages in this chat room. @@ -206,8 +206,8 @@ internal class DefaultMessagesSubscription( throw AblyException.fromErrorInfo( ErrorInfo( "The `end` parameter is specified and is more recent than the subscription point timeserial", - HttpStatusCodes.BadRequest, - ErrorCodes.BadRequest, + HttpStatusCode.BadRequest, + ErrorCode.BadRequest.code, ), ) } @@ -223,9 +223,12 @@ internal class DefaultMessagesSubscription( internal class DefaultMessages( private val roomId: String, - realtimeChannels: AblyRealtime.Channels, + private val realtimeChannels: AblyRealtime.Channels, private val chatApi: ChatApi, -) : Messages { + private val logger: Logger, +) : Messages, ContributesToRoomLifecycleImpl(logger) { + + override val featureName: String = "messages" private var listeners: Map> = emptyMap() @@ -239,7 +242,11 @@ internal class DefaultMessages( */ private val messagesChannelName = "$roomId::\$chat::\$chatMessages" - override val channel: Channel = realtimeChannels.get(messagesChannelName, ChatChannelOptions()) + override val channel = realtimeChannels.get(messagesChannelName, ChatChannelOptions()) + + override val attachmentErrorCode: ErrorCode = ErrorCode.MessagesAttachmentFailed + + override val detachmentErrorCode: ErrorCode = ErrorCode.MessagesDetachmentFailed init { channelStateListener = ChannelStateListener { @@ -253,7 +260,7 @@ internal class DefaultMessages( addListener(listener, deferredChannelSerial) val messageListener = PubSubMessageListener { val pubSubMessage = it ?: throw AblyException.fromErrorInfo( - ErrorInfo("Got empty pubsub channel message", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ErrorInfo("Got empty pubsub channel message", HttpStatusCode.BadRequest, ErrorCode.BadRequest.code), ) val data = parsePubSubMessageData(pubSubMessage.data) val chatMessage = Message( @@ -283,8 +290,8 @@ internal class DefaultMessages( listeners[listener] ?: throw AblyException.fromErrorInfo( ErrorInfo( "This messages subscription instance was already unsubscribed", - HttpStatusCodes.BadRequest, - ErrorCodes.BadRequest, + HttpStatusCode.BadRequest, + ErrorCode.BadRequest.code, ), ) }, @@ -295,14 +302,6 @@ internal class DefaultMessages( override suspend fun send(params: SendMessageParams): Message = chatApi.sendMessage(roomId, params) - override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { - TODO("Not yet implemented") - } - - fun release() { - channel.off(channelStateListener) - } - /** * Associate deferred channel serial value with the current channel's serial * @@ -323,14 +322,22 @@ internal class DefaultMessages( private fun requireChannelSerial(): String { return channel.properties.channelSerial ?: throw AblyException.fromErrorInfo( - ErrorInfo("Channel has been attached, but channelSerial is not defined", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ErrorInfo( + "Channel has been attached, but channelSerial is not defined", + HttpStatusCode.BadRequest, + ErrorCode.BadRequest.code, + ), ) } private fun requireAttachSerial(): String { return channel.properties.attachSerial ?: throw AblyException.fromErrorInfo( - ErrorInfo("Channel has been attached, but attachSerial is not defined", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ErrorInfo( + "Channel has been attached, but attachSerial is not defined", + HttpStatusCode.BadRequest, + ErrorCode.BadRequest.code, + ), ) } @@ -359,6 +366,11 @@ internal class DefaultMessages( } } } + + override fun release() { + channel.off(channelStateListener) + realtimeChannels.release(channel.name) + } } /** @@ -369,7 +381,7 @@ private data class PubSubMessageData(val text: String, val metadata: MessageMeta private fun parsePubSubMessageData(data: Any): PubSubMessageData { if (data !is JsonObject) { throw AblyException.fromErrorInfo( - ErrorInfo("Unrecognized Pub/Sub channel's message for `Message.created` event", HttpStatusCodes.InternalServerError), + ErrorInfo("Unrecognized Pub/Sub channel's message for `Message.created` event", HttpStatusCode.InternalServerError), ) } return PubSubMessageData( 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 1426ceb..f8c6887 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -77,7 +77,14 @@ internal class DefaultOccupancy( private val chatApi: ChatApi, private val roomId: String, private val logger: Logger, -) : Occupancy { +) : Occupancy, ContributesToRoomLifecycleImpl(logger) { + + override val featureName: String = "occupancy" + + override val attachmentErrorCode: ErrorCode = ErrorCode.OccupancyAttachmentFailed + + override val detachmentErrorCode: ErrorCode = ErrorCode.OccupancyDetachmentFailed + // (CHA-O1) private val messagesChannelName = "$roomId::\$chat::\$chatMessages" @@ -138,12 +145,7 @@ internal class DefaultOccupancy( return chatApi.getOccupancy(roomId) } - override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { - // (CHA-O5) - TODO("Not yet implemented") - } - - fun release() { + override fun release() { occupancySubscription.unsubscribe() occupancyScope.cancel() } 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 ca9c298..278a8a3 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -137,7 +137,14 @@ internal class DefaultPresence( private val clientId: String, override val channel: Channel, private val presence: PubSubPresence, -) : Presence { + private val logger: Logger, +) : Presence, ContributesToRoomLifecycleImpl(logger) { + + override val featureName = "presence" + + override val attachmentErrorCode: ErrorCode = ErrorCode.PresenceAttachmentFailed + + override val detachmentErrorCode: ErrorCode = ErrorCode.PresenceDetachmentFailed override suspend fun get(waitForSync: Boolean, clientId: String?, connectionId: String?): List { return presence.getCoroutine(waitForSync, clientId, connectionId).map { user -> @@ -182,13 +189,13 @@ internal class DefaultPresence( } } - override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { - TODO("Not yet implemented") - } - private fun wrapInUserCustomData(data: PresenceData?) = data?.let { JsonObject().apply { add("userCustomData", data) } } + + override fun release() { + // No need to do anything, since it uses same channel as messages + } } 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 fe70d05..ab70ed2 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -2,6 +2,12 @@ package com.ably.chat +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + /** * Represents a chat room. */ @@ -51,20 +57,37 @@ interface Room { */ val occupancy: Occupancy + /** + * Returns the room options. + * + * @returns A copy of the options used to create the room. + */ + val options: RoomOptions + /** * (CHA-RS2) - * Returns an object that can be used to observe the status of the room. + * The current status of the room. * - * @returns The status observable. + * @returns The current status. */ val status: RoomStatus /** - * Returns the room options. - * - * @returns A copy of the options used to create the room. + * The current error, if any, that caused the room to enter the current status. */ - val options: RoomOptions + val error: ErrorInfo? + + /** + * Registers a listener that will be called whenever the room status changes. + * @param listener The function to call when the status changes. + * @returns An object that can be used to unregister the listener. + */ + fun onStatusChange(listener: RoomLifecycle.Listener): Subscription + + /** + * Removes all listeners that were added by the `onStatusChange` method. + */ + fun offAllStatusChange() /** * Attaches to the room to receive events in realtime. @@ -90,71 +113,144 @@ internal class DefaultRoom( private val realtimeClient: RealtimeClient, chatApi: ChatApi, clientId: String, - private val logger: Logger, + logger: Logger, ) : Room { + private val roomLogger = logger.withContext("Room", mapOf("roomId" to roomId)) - private val _messages = DefaultMessages( - roomId = roomId, - realtimeChannels = realtimeClient.channels, - chatApi = chatApi, - ) - - private val _typing: DefaultTyping = DefaultTyping( - roomId = roomId, - realtimeClient = realtimeClient, - options = options.typing, - clientId = clientId, - logger = logger.withContext(tag = "Typing"), - ) + /** + * RoomScope is a crucial part of the Room lifecycle. It manages sequential and atomic operations. + * Parallelism is intentionally limited to 1 to ensure that only one coroutine runs at a time, + * preventing concurrency issues. Every operation within Room must be performed through this scope. + */ + private val roomScope = + CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(roomId) + SupervisorJob()) - private val _occupancy = DefaultOccupancy( + override val messages = DefaultMessages( roomId = roomId, realtimeChannels = realtimeClient.channels, chatApi = chatApi, - logger = logger.withContext(tag = "Occupancy"), + logger = roomLogger.withContext(tag = "Messages"), ) - override val messages: Messages - get() = _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", ErrorCode.BadRequest) + } + return _presence as Presence + } + + private var _reactions: RoomReactions? = null + override val reactions: RoomReactions + get() { + if (_reactions == null) { // CHA-RC2b + throw ablyException("Reactions are not enabled for this room", ErrorCode.BadRequest) + } + return _reactions as RoomReactions + } + private var _typing: Typing? = null override val typing: Typing - get() = _typing + get() { + if (_typing == null) { // CHA-RC2b + throw ablyException("Typing is not enabled for this room", ErrorCode.BadRequest) + } + return _typing as Typing + } + private var _occupancy: Occupancy? = null override val occupancy: Occupancy - get() = _occupancy + get() { + if (_occupancy == null) { // CHA-RC2b + throw ablyException("Occupancy is not enabled for this room", ErrorCode.BadRequest) + } + return _occupancy as Occupancy + } - override val presence: Presence = DefaultPresence( - channel = messages.channel, - clientId = clientId, - presence = messages.channel.presence, - ) - - override val reactions: RoomReactions = DefaultRoomReactions( - roomId = roomId, - clientId = clientId, - realtimeChannels = realtimeClient.channels, - ) + private val statusLifecycle = DefaultRoomLifecycle(roomLogger) override val status: RoomStatus - get() { - TODO("Not yet implemented") + get() = statusLifecycle.status + + override val error: ErrorInfo? + get() = statusLifecycle.error + + private var lifecycleManager: RoomLifecycleManager + + init { + options.validateRoomOptions() // CHA-RC2a + + val roomFeatures = mutableListOf(messages) + + options.presence?.let { + val presenceContributor = DefaultPresence( + clientId = clientId, + channel = messages.channel, + presence = messages.channel.presence, + logger = roomLogger.withContext(tag = "Presence"), + ) + roomFeatures.add(presenceContributor) + _presence = presenceContributor + } + + options.typing?.let { + val typingContributor = DefaultTyping( + roomId = roomId, + realtimeClient = realtimeClient, + clientId = clientId, + options = options.typing, + logger = roomLogger.withContext(tag = "Typing"), + ) + roomFeatures.add(typingContributor) + _typing = typingContributor + } + + options.reactions?.let { + val reactionsContributor = DefaultRoomReactions( + roomId = roomId, + clientId = clientId, + realtimeChannels = realtimeClient.channels, + logger = roomLogger.withContext(tag = "Reactions"), + ) + roomFeatures.add(reactionsContributor) + _reactions = reactionsContributor + } + + options.occupancy?.let { + val occupancyContributor = DefaultOccupancy( + roomId = roomId, + realtimeChannels = realtimeClient.channels, + chatApi = chatApi, + logger = roomLogger.withContext(tag = "Occupancy"), + ) + roomFeatures.add(occupancyContributor) + _occupancy = occupancyContributor } + lifecycleManager = RoomLifecycleManager(roomScope, statusLifecycle, roomFeatures, roomLogger) + } + + override fun onStatusChange(listener: RoomLifecycle.Listener): Subscription = + statusLifecycle.onChange(listener) + + override fun offAllStatusChange() { + statusLifecycle.offAll() + } + override suspend fun attach() { - messages.channel.attachCoroutine() - typing.channel.attachCoroutine() - reactions.channel.attachCoroutine() + lifecycleManager.attach() } override suspend fun detach() { - messages.channel.detachCoroutine() - typing.channel.detachCoroutine() - reactions.channel.detachCoroutine() + lifecycleManager.detach() } - fun release() { - _messages.release() - _typing.release() - _occupancy.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 new file mode 100644 index 0000000..25fe09e --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt @@ -0,0 +1,639 @@ +package com.ably.chat + +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import io.ably.lib.realtime.Channel as AblyRealtimeChannel + +/** + * An interface for features that contribute to the room status. + */ +interface ContributesToRoomLifecycle : EmitsDiscontinuities, HandlesDiscontinuity { + + /** + * Name of the feature + */ + val featureName: String + + /** + * Gets the channel on which the feature operates. This promise is never + * rejected except in the case where room initialization is canceled. + */ + override val channel: AblyRealtimeChannel + + /** + * Gets the ErrorInfo code that should be used when the feature fails to attach. + * @returns The error that should be used when the feature fails to attach. + */ + val attachmentErrorCode: ErrorCode + + /** + * Gets the ErrorInfo code that should be used when the feature fails to detach. + * @returns The error that should be used when the feature fails to detach. + */ + val detachmentErrorCode: ErrorCode + + /** + * Underlying Realtime feature channel is removed from the core SDK to prevent leakage. + * Spec: CHA-RL3h + */ + fun release() +} + +internal abstract class ContributesToRoomLifecycleImpl(logger: Logger) : ContributesToRoomLifecycle { + + private val discontinuityEmitter = DiscontinuityEmitter(logger) + + override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { + discontinuityEmitter.on(listener) + return Subscription { + discontinuityEmitter.off(listener) + } + } + + override fun discontinuityDetected(reason: ErrorInfo?) { + discontinuityEmitter.emit("discontinuity", reason) + } +} + +/** + * The order of precedence for lifecycle operations, passed to PriorityQueueExecutor which allows + * us to ensure that internal operations take precedence over user-driven operations. + */ +enum class LifecycleOperationPrecedence(val priority: Int) { + Internal(1), + Release(2), + AttachOrDetach(3), +} + +/** + * A map of contributors to pending discontinuity events. + */ +typealias DiscontinuityEventMap = MutableMap + +/** + * An internal interface that represents the result of a room attachment operation. + */ +interface RoomAttachmentResult : NewRoomStatus { + val failedFeature: ContributesToRoomLifecycle? + val exception: AblyException +} + +class DefaultRoomAttachmentResult : RoomAttachmentResult { + internal var statusField: RoomStatus = RoomStatus.Attached + override val status: RoomStatus + get() = statusField + + internal var failedFeatureField: ContributesToRoomLifecycle? = null + override val failedFeature: ContributesToRoomLifecycle? + get() = failedFeatureField + + internal var errorField: ErrorInfo? = null + override val error: ErrorInfo? + get() = errorField + + internal var throwable: Throwable? = null + + override val exception: AblyException + get() { + val errorInfo = errorField + ?: lifeCycleErrorInfo("unknown error in attach", ErrorCode.RoomLifecycleError) + return lifeCycleException(errorInfo, throwable) + } +} + +/** + * An implementation of the `Status` interface. + * @internal + */ +internal class RoomLifecycleManager( + private val roomScope: CoroutineScope, + private val statusLifecycle: DefaultRoomLifecycle, + private val contributors: List, + private val logger: Logger, +) { + + /** + * AtomicCoroutineScope makes sure all operations are atomic and run with given priority. + * See [Kotlin Dispatchers](https://kt.academy/article/cc-dispatchers) for more information. + * Spec: CHA-RL7 + */ + private val atomicCoroutineScope = AtomicCoroutineScope(roomScope) + + /** + * This flag indicates whether some sort of controlled operation is in progress (e.g. attaching, detaching, releasing). + * + * 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 + + /** + * A map of pending discontinuity events. + * + * 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() + + /** + * A map of contributors to whether their first attach has completed. + * + * Used to control whether we should trigger discontinuity events. + */ + private val firstAttachesCompleted = mutableMapOf() + + /** + * Retry duration in milliseconds, used by internal doRetry and runDownChannelsOnFailedAttach methods + */ + private val retryDurationInMs: Long = 250 + + init { + // TODO - [CHA-RL4] set up room monitoring here + } + + /** + * Clears all transient detach timeouts - used when some event supersedes the transient detach such + * as a failed channel or suspension. + */ + private fun clearAllTransientDetachTimeouts() { + // This will be implemented as a part of room lifecycle monitoring + } + + /** + * Given some contributor that has entered a suspended state: + * + * - Wind down any other channels + * - Wait for our contributor to recover + * - Attach everything else + * + * Repeat until either of the following happens: + * + * - Our contributor reattaches and we can attach everything else (repeat with the next contributor to break if necessary) + * - The room enters a failed state + * + * @param contributor The contributor that has entered a suspended state. + * @returns Returns when the room is attached, or the room enters a failed state. + * Spec: CHA-RL5 + */ + @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) { + throw result.exceptionOrNull() ?: IllegalStateException("room is in a failed state") + } + 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) + val attachmentResult = doAttach() + + // CHA-RL5c - If we're in failed, then we should wind down all the channels, eventually - but we're done here + if (attachmentResult.status === RoomStatus.Failed) { + atomicCoroutineScope.async(LifecycleOperationPrecedence.Internal.priority) { + runDownChannelsOnFailedAttach() + } + return@coroutineScope + } + + // 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 + ?: throw lifeCycleException( + "no failed feature in doRetry", + ErrorCode.RoomLifecycleError, + ) + // No need to catch errors, rather they should propagate to caller method + return@coroutineScope doRetry(failedFeature) + } + // We attached, huzzah! + } + } + + // If given suspended contributor channel has reattached, then we can retry the attach + if (contributor.channel.state == ChannelState.attached) { + return doAttachWithRetry() + } + + // 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 + // Attach successful + return doAttachWithRetry() + } catch (ex: AblyException) { + // CHA-RL5c - Channel attach failed + statusLifecycle.setStatus(RoomStatus.Failed, ex.errorInfo) + throw ex + } + } + + /** + * CHA-RL5f, CHA-RL5e + */ + private suspend fun listenToChannelAttachOrFailure( + contributor: ContributesToRoomLifecycle, + ) = suspendCancellableCoroutine { continuation -> + // CHA-RL5f + val resumeIfAttached = { + if (continuation.isActive) { + continuation.resume(Unit) + } + } + contributor.channel.once(ChannelState.attached) { + resumeIfAttached() + } + if (contributor.channel.state == ChannelState.attached) { // Just being on the safer side, check if channel got into ATTACHED state + resumeIfAttached() + } + + // CHA-RL5e + val resumeWithExceptionIfFailed = { reason: ErrorInfo? -> + if (continuation.isActive) { + val exception = lifeCycleException( + reason ?: lifeCycleErrorInfo( + "unknown error in doRetry", + ErrorCode.RoomLifecycleError, + ), + ) + continuation.resumeWithException(exception) + } + } + contributor.channel.once(ChannelState.failed) { + resumeWithExceptionIfFailed(it.reason) + } + if (contributor.channel.state == ChannelState.failed) { // Just being on the safer side, check if channel got into FAILED state + resumeWithExceptionIfFailed(contributor.channel.reason) + } + } + + /** + * Try to attach all the channels in a room. + * + * If the operation succeeds, the room enters the attached state and this promise resolves. + * If a channel enters the suspended state, then we reject, but we will retry after a short delay as is the case + * in the core SDK. + * If a channel enters the failed state, we reject and then begin to wind down the other channels. + * Spec: CHA-RL1 + */ + @Suppress("ThrowsCount") + internal suspend fun attach() { + val deferredAttach = atomicCoroutineScope.async(LifecycleOperationPrecedence.AttachOrDetach.priority) { // CHA-RL1d + 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", + ErrorCode.RoomIsReleasing, + ) + } + + if (statusLifecycle.status == RoomStatus.Released) { // CHA-RL1c + throw lifeCycleException( + "unable to attach room; room is released", + ErrorCode.RoomIsReleased, + ) + } + + // At this point, we force the room status to be attaching + clearAllTransientDetachTimeouts() + operationInProgress = true + statusLifecycle.setStatus(RoomStatus.Attaching) // CHA-RL1e + + val attachResult = doAttach() + + // CHA-RL1h4 - If we're in a failed state, then we should wind down all the channels, eventually + if (attachResult.status === RoomStatus.Failed) { + // CHA-RL1h5 - detach all remaining channels + atomicCoroutineScope.async(LifecycleOperationPrecedence.Internal.priority) { + runDownChannelsOnFailedAttach() + } + throw attachResult.exception // CHA-RL1h1 + } + + // 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) { + throw lifeCycleException( + "no failed feature in attach", + ErrorCode.RoomLifecycleError, + ) + } + attachResult.failedFeature?.let { + // CHA-RL1h3 - Enter recovery for failed room feature/contributor + atomicCoroutineScope.async(LifecycleOperationPrecedence.Internal.priority) { + doRetry(it) + } + } + throw attachResult.exception // CHA-RL1h1 + } + + // We attached, finally! + } + + deferredAttach.await() + } + + /** + * + * Attaches each feature channel with rollback on channel attach failure. + * This method is re-usable and can be called as a part of internal room operations. + * Spec: CHA-RL1f, CHA-RL1g, CHA-RL1h + */ + private suspend fun doAttach(): RoomAttachmentResult { + val attachResult = DefaultRoomAttachmentResult() + for (feature in contributors) { // CHA-RL1f - attach each feature sequentially + try { + feature.channel.attachCoroutine() + firstAttachesCompleted[feature] = true + } catch (ex: Throwable) { // CHA-RL1h - handle channel attach failure + attachResult.throwable = ex + attachResult.failedFeatureField = feature + 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 + // If it's in suspended, we wind down the other channels and wait for the reattach + // If it's failed, we can fail the entire room + when (feature.channel.state) { + ChannelState.suspended -> attachResult.statusField = RoomStatus.Suspended + ChannelState.failed -> attachResult.statusField = RoomStatus.Failed + else -> { + attachResult.statusField = RoomStatus.Failed + attachResult.errorField = lifeCycleErrorInfo( + "unexpected channel state in doAttach ${feature.channel.state}${feature.channel.errorMessage}", + ErrorCode.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) + 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 + + // Iterate the pending discontinuity events and trigger them + for ((contributor, error) in pendingDiscontinuityEvents) { + contributor.discontinuityDetected(error) + } + pendingDiscontinuityEvents.clear() + return attachResult + } + + /** + * If we've failed to attach, then we're in the failed state and all that is left to do is to detach all the channels. + * Spec: CHA-RL1h5, CHA-RL1h6 + * @returns Returns only when all channels are detached. Doesn't throw exception. + */ + private suspend fun runDownChannelsOnFailedAttach() { + // At this point, we have control over the channel lifecycle, so we can hold onto it until things are resolved + // Keep trying to detach the channels until they're all detached. + var channelWindDown = kotlin.runCatching { doChannelWindDown() } + 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) + channelWindDown = kotlin.runCatching { doChannelWindDown() } + } + } + + /** + * Detach all features except the one exception provided. + * If the room is in a failed state, then all channels should either reach the failed state or be detached. + * Spec: CHA-RL1h5 + * @param except The contributor to exclude from the detachment. + * @returns Success/Failure when all channels are detached or at least one of them fails. + * + */ + @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) { + 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 + ) && + contributor.channel.state === ChannelState.failed + ) { + return@async + } + + try { + contributor.channel.detachCoroutine() + } catch (throwable: Throwable) { + // 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 + ) { + val contributorError = lifeCycleErrorInfo( + "failed to detach ${contributor.featureName} feature${contributor.channel.errorMessage}", + contributor.detachmentErrorCode, + ) + statusLifecycle.setStatus(RoomStatus.Failed, contributorError) + throw lifeCycleException(contributorError, throwable) + } + + // CHA-RL2h3 - We throw an error so that the promise rejects + throw lifeCycleException(ErrorInfo("detach failure, retry", -1, -1), throwable) + } + } + }.awaitAll() + } + + /** + * Detaches the room. If the room is already detached, this is a no-op. + * If one of the channels fails to detach, the room status will be set to failed. + * If the room is in the process of detaching, this will wait for the detachment to complete. + * Spec: CHA-RL2 + */ + @Suppress("ThrowsCount") + 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) { + return@async + } + // CHA-RL2c - If the room is released, we can't detach + if (statusLifecycle.status === RoomStatus.Released) { + throw lifeCycleException( + "unable to detach room; room is released", + ErrorCode.RoomIsReleased, + ) + } + + // CHA-RL2b - If the room is releasing, we can't detach + if (statusLifecycle.status === RoomStatus.Releasing) { + throw lifeCycleException( + "unable to detach room; room is releasing", + ErrorCode.RoomIsReleasing, + ) + } + + // CHA-RL2d - If we're in failed, we should not attempt to detach + if (statusLifecycle.status === RoomStatus.Failed) { + throw lifeCycleException( + "unable to detach room; room has failed", + ErrorCode.RoomInFailedState, + ) + } + + // CHA-RL2e - We force the room status to be detaching + operationInProgress = true + clearAllTransientDetachTimeouts() + statusLifecycle.setStatus(RoomStatus.Detaching) + + // CHA-RL2f - We now perform an all-channel wind down. + // We keep trying until we reach a suitable conclusion. + return@async doDetach() + } + return deferredDetach.await() + } + + /** + * Perform a detach. + * If detaching a channel fails, we should retry until every channel is either in the detached state, or in the failed state. + * Spec: CHA-RL2f + */ + private suspend fun doDetach() { + var channelWindDown = kotlin.runCatching { doChannelWindDown() } + var firstContributorFailedError: AblyException? = null + while (channelWindDown.isFailure) { // CHA-RL2h + val err = channelWindDown.exceptionOrNull() + if (err is AblyException && err.errorInfo?.code != -1 && firstContributorFailedError == null) { + firstContributorFailedError = err // CHA-RL2h1- First failed contributor error is captured + } + 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) + return + } + + // CHA-RL2h1 - If we're in the failed state, then we need to throw the error + throw firstContributorFailedError + ?: lifeCycleException( + "unknown error in doDetach", + ErrorCode.RoomLifecycleError, + ) + } + + /** + * Releases the room. If the room is already released, this is a no-op. + * Any channel that detaches into the failed state is ok. But any channel that fails to detach + * will cause the room status to be set to failed. + * + * @returns Returns when the room is released. If a channel detaches into a non-terminated + * state (e.g. attached), release will throw exception. + * Spec: CHA-RL3 + */ + 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) { + 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 + ) { + statusLifecycle.setStatus(RoomStatus.Released) + return@async + } + // CHA-RL3l - We force the room status to be releasing. + // Any transient disconnect timeouts shall be cleared. + clearAllTransientDetachTimeouts() + operationInProgress = true + statusLifecycle.setStatus(RoomStatus.Releasing) + + // CHA-RL3f - Do the release until it completes + return@async releaseChannels() + } + deferredRelease.await() + } + + /** + * Releases the room by detaching all channels. If the release operation fails, we wait + * a short period and then try again. + * Spec: CHA-RL3f, CHA-RL3d + */ + private suspend fun releaseChannels() { + var contributorsReleased = kotlin.runCatching { doRelease() } + while (contributorsReleased.isFailure) { + // Wait a short period and then try again + delay(retryDurationInMs) + contributorsReleased = kotlin.runCatching { doRelease() } + } + } + + /** + * Performs the release operation. This will detach all channels in the room that aren't + * already detached or in the failed state. + * Spec: CHA-RL3d, CHA-RL3g + */ + @Suppress("RethrowCaughtException") + private suspend fun doRelease() = coroutineScope { + contributors.map { contributor: ContributesToRoomLifecycle -> + async { + // CHA-RL3e - Failed channels, we can ignore + if (contributor.channel.state == ChannelState.failed) { + return@async + } + // Detached channels, we can ignore + if (contributor.channel.state == ChannelState.detached) { + return@async + } + try { + contributor.channel.detachCoroutine() + } catch (ex: Throwable) { + // TODO - log error here before rethrowing + throw ex + } + } + }.awaitAll() + + // CHA-RL3h - underlying Realtime Channels are released from the core SDK prevent leakage + contributors.forEach { + it.release() + } + statusLifecycle.setStatus(RoomStatus.Released) // CHA-RL3g + } +} diff --git a/chat-android/src/main/java/com/ably/chat/RoomOptions.kt b/chat-android/src/main/java/com/ably/chat/RoomOptions.kt index ae2af90..f30940d 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", ErrorCode.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 3fab956..b338522 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -4,10 +4,10 @@ package com.ably.chat import com.google.gson.JsonObject import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo import io.ably.lib.types.MessageExtras +import io.ably.lib.realtime.Channel as AblyRealtimeChannel /** * This interface is used to interact with room-level reactions in a chat room: subscribing to reactions and sending them. @@ -21,7 +21,7 @@ interface RoomReactions : EmitsDiscontinuities { * * @returns The Ably realtime channel instance. */ - val channel: Channel + val channel: AblyRealtimeChannel /** * Send a reaction to the room including some metadata. @@ -106,12 +106,19 @@ data class SendReactionParams( internal class DefaultRoomReactions( roomId: String, private val clientId: String, - realtimeChannels: AblyRealtime.Channels, -) : RoomReactions { - // (CHA-ER1) + private val realtimeChannels: AblyRealtime.Channels, + private val logger: Logger, +) : RoomReactions, ContributesToRoomLifecycleImpl(logger) { + + override val featureName = "reactions" + private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" - override val channel: Channel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) + override val channel: AblyRealtimeChannel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) + + override val attachmentErrorCode: ErrorCode = ErrorCode.ReactionsAttachmentFailed + + override val detachmentErrorCode: ErrorCode = ErrorCode.ReactionsDetachmentFailed // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. @@ -136,10 +143,10 @@ internal class DefaultRoomReactions( override fun subscribe(listener: RoomReactions.Listener): Subscription { val messageListener = PubSubMessageListener { val pubSubMessage = it ?: throw AblyException.fromErrorInfo( - ErrorInfo("Got empty pubsub channel message", HttpStatusCodes.BadRequest, ErrorCodes.BadRequest), + ErrorInfo("Got empty pubsub channel message", HttpStatusCode.BadRequest, ErrorCode.BadRequest.code), ) val data = pubSubMessage.data as? JsonObject ?: throw AblyException.fromErrorInfo( - ErrorInfo("Unrecognized Pub/Sub channel's message for `roomReaction` event", HttpStatusCodes.InternalServerError), + ErrorInfo("Unrecognized Pub/Sub channel's message for `roomReaction` event", HttpStatusCode.InternalServerError), ) val reaction = Reaction( type = data.requireString("type"), @@ -155,7 +162,7 @@ internal class DefaultRoomReactions( return Subscription { channel.unsubscribe(RoomReactionEventType.Reaction.eventName, messageListener) } } - override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { - TODO("Not yet implemented") + override fun release() { + realtimeChannels.release(channel.name) } } 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 c08846a..9d4a2a0 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt @@ -1,47 +1,13 @@ package com.ably.chat import io.ably.lib.types.ErrorInfo - -/** - * Represents the status of a Room. - */ -interface RoomStatus { - /** - * (CHA-RS2a) - * The current status of the room. - */ - val current: RoomLifecycle - - /** - * (CHA-RS2b) - * The current error, if any, that caused the room to enter the current status. - */ - val error: ErrorInfo? - - /** - * Registers a listener that will be called whenever the room status changes. - * @param listener The function to call when the status changes. - * @returns An object that can be used to unregister the listener. - */ - fun on(listener: Listener): Subscription - - /** - * An interface for listening to changes for the room status - */ - fun interface Listener { - /** - * A function that can be called when the room status changes. - * @param change The change in status. - */ - fun roomStatusChanged(change: RoomStatusChange) - } -} +import io.ably.lib.util.EventEmitter /** * (CHA-RS1) * The different states that a room can be in throughout its lifecycle. */ -enum class RoomLifecycle(val stateName: String) { +enum class RoomStatus(val stateName: String) { /** * (CHA-RS1a) * A temporary state for when the library is first initialized. @@ -105,12 +71,12 @@ data class RoomStatusChange( /** * The new status of the room. */ - val current: RoomLifecycle, + val current: RoomStatus, /** * The previous status of the room. */ - val previous: RoomLifecycle, + val previous: RoomStatus, /** * An error that provides a reason why the room has @@ -118,3 +84,126 @@ data class RoomStatusChange( */ val error: ErrorInfo? = null, ) + +/** + * Represents the status of a Room. + */ +interface RoomLifecycle { + /** + * (CHA-RS2a) + * The current status of the room. + */ + val status: RoomStatus + + /** + * (CHA-RS2b) + * The current error, if any, that caused the room to enter the current status. + */ + val error: ErrorInfo? + + /** + * Registers a listener that will be called whenever the room status changes. + * @param listener The function to call when the status changes. + * @returns An object that can be used to unregister the listener. + */ + fun onChange(listener: Listener): Subscription + + /** + * An interface for listening to changes for the room status + */ + fun interface Listener { + /** + * A function that can be called when the room status changes. + * @param change The change in status. + */ + fun roomStatusChanged(change: RoomStatusChange) + } + + /** + * Removes all listeners that were added by the `onChange` method. + */ + fun offAll() +} + +/** + * A new room status that can be set. + */ +interface NewRoomStatus { + /** + * The new status of the room. + */ + val status: RoomStatus + + /** + * An error that provides a reason why the room has + * entered the new status, if applicable. + */ + val error: ErrorInfo? +} + +/** + * An internal interface for the status of a room, which can be used to separate critical + * internal functionality from user listeners. + * @internal + */ +interface InternalRoomLifecycle : RoomLifecycle { + /** + * Sets the status of the room. + * + * @param params The new status of the room. + */ + fun setStatus(params: NewRoomStatus) +} + +internal class RoomStatusEventEmitter(logger: Logger) : EventEmitter() { + private val logger = logger.withContext("RoomEventEmitter") + + override fun apply(listener: RoomLifecycle.Listener?, event: RoomStatus?, vararg args: Any?) { + try { + if (args.isNotEmpty() && args[0] is RoomStatusChange) { + listener?.roomStatusChanged(args[0] as RoomStatusChange) + } else { + logger.error("Invalid arguments received in apply method") + } + } catch (t: Throwable) { + logger.error("Unexpected exception calling Room Status Listener", t) + } + } +} + +internal class DefaultRoomLifecycle(logger: Logger) : InternalRoomLifecycle { + + private var _status = RoomStatus.Initialized // CHA-RS3 + override val status: RoomStatus + get() = _status + + private var _error: ErrorInfo? = null + override val error: ErrorInfo? + get() = _error + + private val externalEmitter = RoomStatusEventEmitter(logger) + private val internalEmitter = RoomStatusEventEmitter(logger) + + override fun onChange(listener: RoomLifecycle.Listener): Subscription { + externalEmitter.on(listener) + return Subscription { + externalEmitter.off(listener) + } + } + + override fun offAll() { + externalEmitter.off() + } + + override fun setStatus(params: NewRoomStatus) { + setStatus(params.status, params.error) + } + + internal fun setStatus(status: RoomStatus, error: ErrorInfo? = null) { + val change = RoomStatusChange(status, _status, error) + _status = change.current + _error = change.error + internalEmitter.emit(change.current, change) + externalEmitter.emit(change.current, change) + } +} 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 e6c6371..7c35d44 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) } @@ -49,35 +64,108 @@ internal class DefaultRooms( private val clientId: String, private val logger: Logger, ) : 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, - clientId = clientId, - logger = logger, - ) + 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", ErrorCode.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), + 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", + ErrorCode.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) { - synchronized(this) { - 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 { throwable -> + throwable?.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.copy(), realtimeClient, chatApi, clientId, logger) } diff --git a/chat-android/src/main/java/com/ably/chat/Timeserial.kt b/chat-android/src/main/java/com/ably/chat/Timeserial.kt index 99b161d..e7344e4 100644 --- a/chat-android/src/main/java/com/ably/chat/Timeserial.kt +++ b/chat-android/src/main/java/com/ably/chat/Timeserial.kt @@ -49,7 +49,7 @@ data class Timeserial( fun parse(timeserial: String): Timeserial { val matched = """(\w+)@(\d+)-(\d+)(?::(\d+))?""".toRegex().matchEntire(timeserial) ?: throw AblyException.fromErrorInfo( - ErrorInfo("invalid timeserial", HttpStatusCodes.InternalServerError, ErrorCodes.InternalError), + ErrorInfo("invalid timeserial", HttpStatusCode.InternalServerError, ErrorCode.InternalError.code), ) val (seriesId, timestamp, counter, index) = matched.destructured 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 b4237a2..9b442bd 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -92,13 +92,19 @@ data class TypingEvent(val currentlyTyping: Set) internal class DefaultTyping( roomId: String, - realtimeClient: RealtimeClient, + private val realtimeClient: RealtimeClient, private val clientId: String, private val options: TypingOptions?, private val logger: Logger, -) : Typing { +) : Typing, ContributesToRoomLifecycleImpl(logger) { private val typingIndicatorsChannelName = "$roomId::\$chat::\$typingIndicators" + override val featureName = "typing" + + override val attachmentErrorCode: ErrorCode = ErrorCode.TypingAttachmentFailed + + override val detachmentErrorCode: ErrorCode = ErrorCode.TypingDetachmentFailed + private val typingScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) private val eventBus = MutableSharedFlow( @@ -176,20 +182,17 @@ internal class DefaultTyping( }.join() } - override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { - TODO("Not yet implemented") - } - - fun release() { + override fun release() { presenceSubscription.unsubscribe() typingScope.cancel() + realtimeClient.channels.release(channel.name) } private fun startTypingTimer() { val timeout = options?.timeoutMs ?: throw AblyException.fromErrorInfo( ErrorInfo( "Typing options hasn't been initialized", - ErrorCodes.BadRequest, + ErrorCode.BadRequest.code, ), ) logger.trace("DefaultTyping.startTypingTimer()") 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 2584d2e..6b361bb 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -127,6 +127,13 @@ suspend fun PubSubPresence.leaveClientCoroutine(clientId: String, data: JsonElem ) } +val Channel.errorMessage: String + get() = if (reason == null) { + "" + } else { + ", ${reason.message}" + } + @Suppress("FunctionName") fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { val options = ChannelOptions() @@ -197,3 +204,46 @@ internal class DeferredValue { return result } } + +fun lifeCycleErrorInfo( + errorMessage: String, + errorCode: ErrorCode, +) = createErrorInfo(errorMessage, errorCode, HttpStatusCode.InternalServerError) + +fun lifeCycleException( + errorMessage: String, + errorCode: ErrorCode, + cause: Throwable? = null, +): AblyException = createAblyException(lifeCycleErrorInfo(errorMessage, errorCode), cause) + +fun lifeCycleException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +fun ablyException( + errorMessage: String, + errorCode: ErrorCode, + statusCode: Int = HttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) + return createAblyException(errorInfo, cause) +} + +fun ablyException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +private fun createErrorInfo( + errorMessage: String, + errorCode: ErrorCode, + statusCode: Int, +) = ErrorInfo(errorMessage, statusCode, errorCode.code) + +private fun createAblyException( + errorInfo: ErrorInfo, + cause: Throwable?, +) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } + ?: AblyException.fromErrorInfo(errorInfo) diff --git a/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt b/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt new file mode 100644 index 0000000..3fc94c5 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/AtomicCoroutineScopeTest.kt @@ -0,0 +1,285 @@ +package com.ably.chat + +import io.ably.lib.types.AblyException +import java.util.concurrent.LinkedBlockingQueue +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.hamcrest.CoreMatchers.containsString +import org.junit.Assert +import org.junit.Test + +class AtomicCoroutineScopeTest { + + @Test + fun `should perform given operation`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResult = atomicCoroutineScope.async { + delay(3000) + return@async "Operation Success!" + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + val result = deferredResult.await() + assertWaiter { atomicCoroutineScope.finishedProcessing } + Assert.assertEquals("Operation Success!", result) + } + + @Test + fun `should capture failure of the given operation and continue performing other operation`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResult1 = atomicCoroutineScope.async { + delay(2000) + throw clientError("Error performing operation") + } + val deferredResult2 = atomicCoroutineScope.async { + delay(2000) + return@async "Operation Success!" + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + + val ex = Assert.assertThrows(AblyException::class.java) { + runBlocking { + deferredResult1.await() + } + } + Assert.assertEquals("Error performing operation", ex.errorInfo.message) + + val result2 = deferredResult2.await() + assertWaiter { atomicCoroutineScope.finishedProcessing } + Assert.assertEquals("Operation Success!", result2) + } + + @Test + fun `should perform mutually exclusive operations`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResults = mutableListOf>() + var operationInProgress = false + var counter = 0 + + repeat(10) { + val result = atomicCoroutineScope.async { + if (operationInProgress) { + error("Can't perform operation when other operation is going on") + } + operationInProgress = true + delay((200..800).random().toDuration(DurationUnit.MILLISECONDS)) + val returnValue = counter++ + operationInProgress = false + return@async returnValue + } + deferredResults.add(result) + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + + val results = deferredResults.awaitAll() + assertWaiter { atomicCoroutineScope.finishedProcessing } + + repeat(10) { + Assert.assertEquals(it, results[it]) + } + } + + @Test + fun `Concurrently perform mutually exclusive operations`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResults = LinkedBlockingQueue>() + + var operationInProgress = false + var counter = 0 + val countedValues = mutableListOf() + + // Concurrently schedule 100000 jobs from multiple threads + withContext(Dispatchers.IO) { + repeat(1_00_000) { + launch { + val result = atomicCoroutineScope.async { + if (operationInProgress) { + error("Can't perform operation when other operation is going on") + } + operationInProgress = true + countedValues.add(counter++) + operationInProgress = false + } + deferredResults.add(result) + } + } + } + + assertWaiter { deferredResults.size == 1_00_000 } + + deferredResults.awaitAll() + assertWaiter { atomicCoroutineScope.finishedProcessing } + Assert.assertEquals((0..99_999).toList(), countedValues) + } + + @Test + fun `should perform mutually exclusive operations with custom room scope`() = runTest { + val roomScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId")) + val atomicCoroutineScope = AtomicCoroutineScope(roomScope) + val deferredResults = mutableListOf>() + + val contexts = mutableListOf() + val contextNames = mutableListOf() + + var operationInProgress = false + var counter = 0 + + repeat(10) { + val result = atomicCoroutineScope.async { + if (operationInProgress) { + error("Can't perform operation when other operation is going on") + } + operationInProgress = true + contexts.add(coroutineContext.toString()) + contextNames.add(coroutineContext[CoroutineName]!!.name) + + delay((200..800).random().toDuration(DurationUnit.MILLISECONDS)) + val returnValue = counter++ + operationInProgress = false + return@async returnValue + } + deferredResults.add(result) + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + + val results = deferredResults.awaitAll() + repeat(10) { + Assert.assertEquals(it, results[it]) + Assert.assertEquals("roomId", contextNames[it]) + Assert.assertThat(contexts[it], containsString("Dispatchers.Default.limitedParallelism(1)")) + } + assertWaiter { atomicCoroutineScope.finishedProcessing } + } + + @Test + fun `should perform mutually exclusive operations with given priority`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResults = mutableListOf>() + var operationInProgress = false + var counter = 0 + val contexts = mutableListOf() + + // This will start internal operation + deferredResults.add( + atomicCoroutineScope.async { + delay(1000) + return@async 99 + }, + ) + delay(100) + + // Add more jobs, will be processed based on priority + repeat(10) { + val result = atomicCoroutineScope.async(10 - it) { + if (operationInProgress) { + error("Can't perform operation when other operation is going on") + } + operationInProgress = true + contexts.add(this.coroutineContext.toString()) + delay((200..800).random().toDuration(DurationUnit.MILLISECONDS)) + val returnValue = counter++ + operationInProgress = false + return@async returnValue + } + deferredResults.add(result) + } + + val results = deferredResults.awaitAll() + val expectedResults = listOf(99, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0) + repeat(10) { + Assert.assertEquals(expectedResults[it], results[it]) + Assert.assertThat(contexts[it], containsString("Dispatchers.Default")) + } + assertWaiter { atomicCoroutineScope.finishedProcessing } + } + + @Test + fun `Concurrently execute mutually exclusive operations with given priority`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val deferredResults = LinkedBlockingQueue>() + + var operationInProgress = false + val processedValues = mutableListOf() + +// This will start first internal operation + deferredResults.add( + atomicCoroutineScope.async { + delay(1000) + processedValues.add(1000) + return@async + }, + ) + + // Add more jobs, will be processed based on priority + // Concurrently schedule 1000 jobs with incremental priority from multiple threads + withContext(Dispatchers.IO) { + repeat(1000) { + launch { + val result = atomicCoroutineScope.async(1000 - it) { + if (operationInProgress) { + error("Can't perform operation when other operation is going on") + } + operationInProgress = true + processedValues.add(it) + operationInProgress = false + } + deferredResults.add(result) + } + } + } + + deferredResults.awaitAll() + val expectedResults = (1000 downTo 0).toList() + repeat(1001) { + Assert.assertEquals(expectedResults[it], processedValues[it]) + } + assertWaiter { atomicCoroutineScope.finishedProcessing } + } + + @Test + fun `should cancel current+pending operations once scope is cancelled and continue performing new operations`() = runTest { + val atomicCoroutineScope = AtomicCoroutineScope() + val results = mutableListOf>() + repeat(10) { + results.add( + atomicCoroutineScope.async { + delay(10_000) + }, + ) + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + Assert.assertEquals(9, atomicCoroutineScope.pendingJobCount) + + // Cancelling scope should cancel current job and other queued jobs + atomicCoroutineScope.cancel("scope cancelled externally") + assertWaiter { atomicCoroutineScope.finishedProcessing } + Assert.assertEquals(0, atomicCoroutineScope.pendingJobCount) + + for (result in results) { + val result1 = kotlin.runCatching { result.await() } + Assert.assertTrue(result1.isFailure) + Assert.assertEquals("scope cancelled externally", result1.exceptionOrNull()!!.message) + } + + // Should process new job + val deferredResult3 = atomicCoroutineScope.async { + delay(200) + return@async "Operation Success!" + } + assertWaiter { !atomicCoroutineScope.finishedProcessing } + + val result3 = deferredResult3.await() + assertWaiter { atomicCoroutineScope.finishedProcessing } + Assert.assertEquals("Operation Success!", result3) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/MessagesTest.kt b/chat-android/src/test/java/com/ably/chat/MessagesTest.kt index af6e263..d9dd6c5 100644 --- a/chat-android/src/test/java/com/ably/chat/MessagesTest.kt +++ b/chat-android/src/test/java/com/ably/chat/MessagesTest.kt @@ -1,5 +1,6 @@ package com.ably.chat +import com.ably.chat.room.createMockLogger import com.google.gson.JsonObject import io.ably.lib.realtime.AblyRealtime.Channels import io.ably.lib.realtime.Channel @@ -30,6 +31,7 @@ class MessagesTest { private val realtimeChannel = spyk(buildRealtimeChannel()) private val chatApi = spyk(ChatApi(realtimeClient, "clientId", EmptyLogger(LogContext(tag = "TEST")))) private lateinit var messages: DefaultMessages + private val logger = createMockLogger() private val channelStateListenerSlot = slot() @@ -45,6 +47,7 @@ class MessagesTest { roomId = "room1", realtimeChannels = realtimeChannels, chatApi = chatApi, + logger, ) } diff --git a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt index c43f421..3a302ed 100644 --- a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt +++ b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt @@ -1,5 +1,6 @@ package com.ably.chat +import com.ably.chat.room.createMockLogger import com.google.gson.JsonObject import com.google.gson.JsonPrimitive import io.ably.lib.realtime.Channel @@ -20,6 +21,7 @@ class PresenceTest { private val pubSubChannel = spyk(buildRealtimeChannel("room1::\$chat::\$messages")) private val pubSubPresence = mockk(relaxed = true) private lateinit var presence: DefaultPresence + private val logger = createMockLogger() @Before fun setUp() { @@ -27,6 +29,7 @@ class PresenceTest { clientId = "client1", channel = pubSubChannel, presence = pubSubPresence, + logger, ) } diff --git a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt index 182c4e9..2d6bf32 100644 --- a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt +++ b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt @@ -1,5 +1,6 @@ package com.ably.chat +import com.ably.chat.room.createMockLogger import com.google.gson.JsonObject import io.ably.lib.realtime.AblyRealtime.Channels import io.ably.lib.realtime.Channel @@ -19,6 +20,7 @@ class RoomReactionsTest { private val realtimeChannels = mockk(relaxed = true) private val realtimeChannel = spyk(buildRealtimeChannel("room1::\$chat::\$reactions")) private lateinit var roomReactions: DefaultRoomReactions + private val logger = createMockLogger() @Before fun setUp() { @@ -35,6 +37,7 @@ class RoomReactionsTest { roomId = "room1", clientId = "client1", realtimeChannels = realtimeChannels, + logger, ) } @@ -47,6 +50,7 @@ class RoomReactionsTest { roomId = "foo", clientId = "client1", realtimeChannels = realtimeChannels, + logger, ) assertEquals( diff --git a/chat-android/src/test/java/com/ably/chat/Sandbox.kt b/chat-android/src/test/java/com/ably/chat/Sandbox.kt index ec6f5f7..fd74e3b 100644 --- a/chat-android/src/test/java/com/ably/chat/Sandbox.kt +++ b/chat-android/src/test/java/com/ably/chat/Sandbox.kt @@ -3,6 +3,8 @@ 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 @@ -15,6 +17,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess +import kotlinx.coroutines.CompletableDeferred private val client = HttpClient(CIO) { install(HttpRequestRetry) { @@ -61,6 +64,30 @@ internal fun Sandbox.createSandboxRealtime(chatClientId: String): AblyRealtime = }, ) +internal suspend fun Sandbox.getConnectedChatClient(chatClientId: String = "sandbox-client"): DefaultChatClient { + val realtime = createSandboxRealtime(chatClientId) + 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) + this.connection.off() + } 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") { diff --git a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt index 9f15b19..9b1a0b9 100644 --- a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt +++ b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt @@ -9,10 +9,12 @@ import org.junit.Test class SandboxTest { + private val roomOptions = RoomOptions.default + @Test 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()) + val room = chatClient.rooms.get(UUID.randomUUID().toString(), roomOptions) room.attach() val members = room.presence.get() assertEquals(0, members.size) @@ -21,7 +23,7 @@ class SandboxTest { @Test fun `should return yourself as presence member after you entered`() = runTest { val chatClient = sandbox.createSandboxChatClient("sandbox-client") - val room = chatClient.rooms.get(UUID.randomUUID().toString()) + val room = chatClient.rooms.get(UUID.randomUUID().toString(), roomOptions) room.attach() room.presence.enter() val members = room.presence.get() 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 e7982fa..a7f6d30 100644 --- a/chat-android/src/test/java/com/ably/chat/TestUtils.kt +++ b/chat-android/src/test/java/com/ably/chat/TestUtils.kt @@ -4,6 +4,11 @@ import com.google.gson.JsonElement import io.ably.lib.types.AsyncHttpPaginatedResponse import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout fun buildAsyncHttpPaginatedResponse(items: List): AsyncHttpPaginatedResponse { val response = mockk() @@ -62,3 +67,39 @@ fun Occupancy.subscribeOnce(listener: Occupancy.Listener) { subscription.unsubscribe() } } + +suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: () -> Boolean) { + withContext(Dispatchers.Default) { + withTimeout(timeoutInMs) { + do { + val success = block() + delay(100) + } while (!success) + } + } +} + +fun Any.setPrivateField(name: String, value: Any?) { + val valueField = javaClass.getDeclaredField(name) + valueField.isAccessible = true + return valueField.set(this, value) +} + +fun Any.getPrivateField(name: String): T { + val valueField = javaClass.getDeclaredField(name) + valueField.isAccessible = true + @Suppress("UNCHECKED_CAST") + return valueField.get(this) as T +} + +suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?) = suspendCancellableCoroutine { cont -> + val suspendMethod = javaClass.declaredMethods.find { it.name == methodName } + suspendMethod?.let { + it.isAccessible = true + it.invoke(this, *args, cont) + } +} + +fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest) + +fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.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 0000000..b8e745e --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt @@ -0,0 +1,94 @@ +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 { + + private val clientId = "clientId" + private val logger = createMockLogger() + + @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, clientId, logger) + 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, clientId, logger) + } + 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, clientId, logger) + 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, clientId, logger) + Assert.assertNotNull(roomWithAllFeatures.presence) + Assert.assertNotNull(roomWithAllFeatures.reactions) + Assert.assertNotNull(roomWithAllFeatures.typing) + Assert.assertNotNull(roomWithAllFeatures.occupancy) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt b/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt new file mode 100644 index 0000000..ae3bc85 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomGetTest.kt @@ -0,0 +1,220 @@ +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 { + private val clientId = "clientId" + private val logger = createMockLogger() + + @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(), clientId, logger) + 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(), clientId, logger), 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(), clientId, logger), 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(), clientId, logger), 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(), clientId, logger), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger), + 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, clientId, logger) + } + 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 0000000..ba10b7d --- /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 0000000..dde7660 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomReleaseTest.kt @@ -0,0 +1,285 @@ +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.ErrorCode +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 { + + private val clientId = "clientId" + private val logger = createMockLogger() + + @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(), clientId, logger), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger), + 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(), clientId, logger), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger), + 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(), clientId, logger), 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(), clientId, logger), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger), + 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(), clientId, logger), recordPrivateCalls = true) + + val defaultRoom = spyk( + DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger), + 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, clientId, logger) + } + 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(ErrorCode.RoomReleasedBeforeOperationCompleted.code, 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/chat/room/RoomTestHelpers.kt b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt new file mode 100644 index 0000000..48035de --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt @@ -0,0 +1,78 @@ +package com.ably.chat.room + +import com.ably.chat.AndroidLogger +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.Logger +import com.ably.chat.Room +import com.ably.chat.RoomLifecycleManager +import com.ably.chat.RoomOptions +import com.ably.chat.Rooms +import com.ably.chat.getPrivateField +import com.ably.chat.invokePrivateSuspendMethod +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.ChannelState +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 })) +internal fun createMockLogger(): Logger = mockk(relaxed = true) + +// Rooms mocks +val Rooms.RoomIdToRoom get() = getPrivateField>("roomIdToRoom") +val Rooms.RoomGetDeferred get() = getPrivateField>>("roomGetDeferred") +val Rooms.RoomReleaseDeferred get() = getPrivateField>>("roomReleaseDeferred") + +// Room mocks +internal val Room.StatusLifecycle get() = getPrivateField("statusLifecycle") +internal val Room.LifecycleManager get() = getPrivateField("lifecycleManager") + +// RoomLifeCycleManager Mocks +internal fun RoomLifecycleManager.atomicCoroutineScope(): AtomicCoroutineScope = getPrivateField("atomicCoroutineScope") + +internal suspend fun RoomLifecycleManager.retry(exceptContributor: ContributesToRoomLifecycle) = + invokePrivateSuspendMethod("doRetry", exceptContributor) + +internal suspend fun RoomLifecycleManager.atomicRetry(exceptContributor: ContributesToRoomLifecycle) { + atomicCoroutineScope().async(LifecycleOperationPrecedence.Internal.priority) { + retry(exceptContributor) + }.await() +} + +fun createRoomFeatureMocks(roomId: String = "1234"): List { + val clientId = "clientId" + + val realtimeClient = createMockRealtimeClient() + val chatApi = mockk(relaxed = true) + val logger = createMockLogger() + + val messagesContributor = spyk(DefaultMessages(roomId, realtimeClient.channels, chatApi, logger), recordPrivateCalls = true) + val presenceContributor = spyk( + DefaultPresence(clientId, messagesContributor.channel, messagesContributor.channel.presence, logger), + recordPrivateCalls = true, + ) + val occupancyContributor = spyk(DefaultOccupancy(realtimeClient.channels, chatApi, roomId, logger), recordPrivateCalls = true) + val typingContributor = spyk( + DefaultTyping(roomId, realtimeClient, clientId, RoomOptions.default.typing, logger), + recordPrivateCalls = true, + ) + val reactionsContributor = spyk(DefaultRoomReactions(roomId, clientId, realtimeClient.channels, logger), 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/lifecycle/AttachTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/AttachTest.kt new file mode 100644 index 0000000..9faf157 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/AttachTest.kt @@ -0,0 +1,445 @@ +package com.ably.chat.room.lifecycle + +import com.ably.chat.ContributesToRoomLifecycle +import com.ably.chat.DefaultRoomLifecycle +import com.ably.chat.ErrorCode +import com.ably.chat.HttpStatusCode +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.createMockLogger +import com.ably.chat.room.createRoomFeatureMocks +import com.ably.chat.room.setState +import com.ably.chat.serverError +import com.ably.chat.setPrivateField +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RL1 + */ +class AttachTest { + + private val logger = createMockLogger() + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + @After + fun tearDown() { + unmockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + } + + @Test + fun `(CHA-RL1a) Attach success when room is already in attached state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1b) Attach throws exception when room in releasing state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Releasing) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.attach() + } + } + Assert.assertEquals("unable to attach room; room is releasing", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleasing.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1c) Attach throws exception when room in released state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Released) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, listOf(), logger)) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.attach() + } + } + Assert.assertEquals("unable to attach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleased.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1d) Attach op should wait for existing operation as per (CHA-RL7)`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + Assert.assertEquals(RoomStatus.Initialized, statusLifecycle.status) // CHA-RS3 + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + + val roomReleased = Channel() + coEvery { + roomLifecycle.release() + } coAnswers { + roomLifecycle.atomicCoroutineScope().async { + 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, one job running + assertWaiter { statusLifecycle.status == RoomStatus.Releasing } + + // Attach op started from separate coroutine + val roomAttachOpDeferred = async(SupervisorJob()) { roomLifecycle.attach() } + assertWaiter { roomLifecycle.atomicCoroutineScope().pendingJobCount == 1 } // attach op queued + Assert.assertEquals(RoomStatus.Releasing, statusLifecycle.status) + + // Finish release op, so ATTACH op can start + roomReleased.send(true) + assertWaiter { statusLifecycle.status == RoomStatus.Released } + + val result = kotlin.runCatching { roomAttachOpDeferred.await() } + Assert.assertTrue(roomLifecycle.atomicCoroutineScope().finishedProcessing) + + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals("unable to attach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleased.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + coVerify { roomLifecycle.release() } + } + + @Test + fun `(CHA-RL1e) Attach op should transition room into ATTACHING state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + val roomStatusChanges = mutableListOf() + statusLifecycle.onChange { + roomStatusChanges.add(it) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, emptyList(), logger)) + roomLifecycle.attach() + + Assert.assertEquals(RoomStatus.Attaching, roomStatusChanges[0].current) + Assert.assertEquals(RoomStatus.Attached, roomStatusChanges[1].current) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1f) Attach op should attach each contributor channel sequentially`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + val capturedChannels = mutableListOf() + coEvery { any().attachCoroutine() } coAnswers { + capturedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + roomLifecycle.attach() + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertTrue(result.isSuccess) + + Assert.assertEquals(5, capturedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, capturedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[4].name) + } + + @Test + fun `(CHA-RL1g) When all contributor channels ATTACH, op is complete and room should be considered ATTACHED`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } returns Unit + + val contributors = createRoomFeatureMocks("1234") + val contributorErrors = mutableListOf() + for (contributor in contributors) { + every { + contributor.discontinuityDetected(capture(contributorErrors)) + } returns Unit + } + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) { + val pendingDiscontinuityEvents = mutableMapOf().apply { + for (contributor in contributors) { + put(contributor, ErrorInfo("${contributor.channel.name} error", 500)) + } + } + this.setPrivateField("pendingDiscontinuityEvents", pendingDiscontinuityEvents) + } + justRun { roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" } + + val result = kotlin.runCatching { roomLifecycle.attach() } + + // CHA-RL1g1 + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Attached, statusLifecycle.status) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + // CHA-RL1g2 + verify(exactly = 1) { + for (contributor in contributors) { + contributor.discontinuityDetected(any()) + } + } + Assert.assertEquals(5, contributorErrors.size) + + // CHA-RL1g3 + verify(exactly = 1) { + roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" + } + } + + // All of the following tests cover sub-spec points under CHA-RL1h ( channel attach failure ) + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h1, CHA-RL1h2) If a one of the contributors fails to attach (enters suspended state), attach throws related error and room enters suspended state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("reactions" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + channel.setState(ChannelState.suspended) + throw serverError("error attaching channel ${channel.name}") + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Suspended, statusLifecycle.status) + + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals("failed to attach reactions feature", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.ReactionsAttachmentFailed.code, exception.errorInfo.code) + Assert.assertEquals(500, exception.errorInfo.statusCode) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h1, CHA-RL1h4) If a one of the contributors fails to attach (enters failed state), attach throws related error and room enters failed state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // 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(error) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals( + "failed to attach typing feature, error attaching channel 1234::\$chat::\$typingIndicators", + exception.errorInfo.message, + ) + Assert.assertEquals(ErrorCode.TypingAttachmentFailed.code, exception.errorInfo.code) + Assert.assertEquals(500, exception.errorInfo.statusCode) + } + + @Test + fun `(CHA-RL1h3) When room enters suspended state (CHA-RL1h2), it should enter recovery loop as per (CHA-RL5)`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("reactions" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + channel.setState(ChannelState.suspended) + throw serverError("error attaching channel ${channel.name}") + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val capturedContributors = slot() + + // Behaviour for CHA-RL5 will be tested as a part of sub spec for the same + coEvery { roomLifecycle["doRetry"](capture(capturedContributors)) } coAnswers { + delay(1000) + } + + val result = kotlin.runCatching { roomLifecycle.attach() } + assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } // internal attach retry in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Suspended, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for doRetry to finish + + coVerify(exactly = 1) { + roomLifecycle["doRetry"](capturedContributors.captured) + } + Assert.assertEquals("reactions", capturedContributors.captured.featureName) + } + + @Test + fun `(CHA-RL1h5) When room enters failed state (CHA-RL1h4), room detach all channels not in failed state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // 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(error) + } + } + + val detachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + detachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertFalse(roomLifecycle.atomicCoroutineScope().finishedProcessing) // Internal channels detach in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for channels detach + + coVerify { + roomLifecycle invokeNoArgs "runDownChannelsOnFailedAttach" + } + + coVerify(exactly = 1) { + roomLifecycle["doChannelWindDown"](any()) + } + + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$reactions", detachedChannels[3].name) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h6) When room enters failed state, when CHA-RL1h5 fails to detach, op will be repeated till all channels are detached`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // 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(error) + } + } + + var failDetachTimes = 5 + val detachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + if (--failDetachTimes >= 0) { + error("failed to detach channel") + } + detachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertFalse(roomLifecycle.atomicCoroutineScope().finishedProcessing) // Internal channels detach in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for channels detach + + coVerify { + roomLifecycle invokeNoArgs "runDownChannelsOnFailedAttach" + } + + // Channel detach success on 6th call + coVerify(exactly = 6) { + roomLifecycle["doChannelWindDown"](any()) + } + + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$reactions", detachedChannels[3].name) + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt new file mode 100644 index 0000000..256bbf5 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/DetachTest.kt @@ -0,0 +1,357 @@ +package com.ably.chat.room.lifecycle + +import com.ably.chat.ContributesToRoomLifecycle +import com.ably.chat.DefaultRoomLifecycle +import com.ably.chat.ErrorCode +import com.ably.chat.HttpStatusCode +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.createMockLogger +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 +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.justRun +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RL2 + */ +class DetachTest { + + private val logger = createMockLogger() + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + @After + fun tearDown() { + unmockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + } + + @Test + fun `(CHA-RL2a) Detach success when room is already in detached state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Detached) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + val result = kotlin.runCatching { roomLifecycle.detach() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL2b) Detach throws exception when room in releasing state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Releasing) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.detach() + } + } + Assert.assertEquals("unable to detach room; room is releasing", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleasing.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL2c) Detach throws exception when room in released state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Released) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, listOf(), logger)) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.detach() + } + } + Assert.assertEquals("unable to detach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleased.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL2d) Detach throws exception when room in failed state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Failed) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, listOf(), logger)) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.detach() + } + } + Assert.assertEquals("unable to detach room; room has failed", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomInFailedState.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL2e) Detach op should transition room into DETACHING state, transient timeouts should be cleared`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + val roomStatusChanges = mutableListOf() + statusLifecycle.onChange { + roomStatusChanges.add(it) + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, emptyList(), logger), recordPrivateCalls = true) + justRun { roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" } + + roomLifecycle.detach() + Assert.assertEquals(RoomStatus.Detaching, roomStatusChanges[0].current) + Assert.assertEquals(RoomStatus.Detached, roomStatusChanges[1].current) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + verify(exactly = 1) { + roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" + } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL2f, CHA-RL2g) Detach op should detach each contributor channel sequentially and room should be considered DETACHED`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + val capturedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + capturedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + val result = kotlin.runCatching { roomLifecycle.detach() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Detached, statusLifecycle.status) + + Assert.assertEquals(5, capturedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, capturedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[4].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL2i) Detach op should wait for existing operation as per (CHA-RL7)`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + Assert.assertEquals(RoomStatus.Initialized, statusLifecycle.status) // CHA-RS3 + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + + val roomReleased = Channel() + coEvery { + roomLifecycle.release() + } coAnswers { + roomLifecycle.atomicCoroutineScope().async { + 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, one job running + assertWaiter { statusLifecycle.status == RoomStatus.Releasing } + + // Detach op started from separate coroutine + val roomDetachOpDeferred = async(SupervisorJob()) { roomLifecycle.detach() } + assertWaiter { roomLifecycle.atomicCoroutineScope().pendingJobCount == 1 } // detach op queued + Assert.assertEquals(RoomStatus.Releasing, statusLifecycle.status) + + // Finish release op, so DETACH op can start + roomReleased.send(true) + assertWaiter { statusLifecycle.status == RoomStatus.Released } + + val result = kotlin.runCatching { roomDetachOpDeferred.await() } + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals("unable to detach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCode.RoomIsReleased.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + + coVerify { roomLifecycle.release() } + } + + // All of the following tests cover sub-spec points under CHA-RL2h ( channel detach failure ) + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL2h1) If a one of the contributors fails to detach (enters failed state), then room enters failed state, detach op continues for other contributors`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + // Fail detach for both typing and reactions, should capture error for first failed contributor + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + 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(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(error) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.detach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + val exception = result.exceptionOrNull() as AblyException + + // ErrorInfo for the first failed contributor + Assert.assertEquals( + "failed to detach typing feature, error detaching channel 1234::\$chat::\$typingIndicators", + exception.errorInfo.message, + ) + Assert.assertEquals(ErrorCode.TypingDetachmentFailed.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + + // The same ErrorInfo must accompany the FAILED room status + Assert.assertSame(statusLifecycle.error, exception.errorInfo) + + // First fail for typing, second fail for reactions, third is a success + coVerify(exactly = 3) { + roomLifecycle["doChannelWindDown"](any()) + } + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL2h2) If multiple contributors fails to detach (enters failed state), then failed status should be emitted only once`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + val failedRoomEvents = mutableListOf() + statusLifecycle.onChange { + if (it.current == RoomStatus.Failed) { + failedRoomEvents.add(it) + } + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + val error = ErrorInfo("error detaching channel ${channel.name}", 500) + channel.setState(ChannelState.failed, 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(error) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.detach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(1, failedRoomEvents.size) + Assert.assertEquals(RoomStatus.Detaching, failedRoomEvents[0].previous) + Assert.assertEquals(RoomStatus.Failed, failedRoomEvents[0].current) + + // Emit error for the first failed contributor + val error = failedRoomEvents[0].error as ErrorInfo + Assert.assertEquals( + "failed to detach typing feature, error detaching channel 1234::\$chat::\$typingIndicators", + error.message, + ) + Assert.assertEquals(ErrorCode.TypingDetachmentFailed.code, error.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, error.statusCode) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL2h3) If channel fails to detach entering another state (ATTACHED), detach will be retried until finally detached`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + val roomEvents = mutableListOf() + statusLifecycle.onChange { + roomEvents.add(it) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + var failDetachTimes = 5 + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + delay(200) + if (--failDetachTimes >= 0) { + channel.setState(ChannelState.attached) + error("failed to detach channel") + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.detach() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Detached, statusLifecycle.status) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(0, roomEvents.filter { it.current == RoomStatus.Failed }.size) // Zero failed room status events emitted + Assert.assertEquals(1, roomEvents.filter { it.current == RoomStatus.Detached }.size) // Only one detach event received + + // Channel detach success on 6th call + coVerify(exactly = 6) { + roomLifecycle["doChannelWindDown"](any()) + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt new file mode 100644 index 0000000..527a881 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/PrecedenceTest.kt @@ -0,0 +1,120 @@ +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.RoomLifecycleManager +import com.ably.chat.RoomStatus +import com.ably.chat.RoomStatusChange +import com.ably.chat.assertWaiter +import com.ably.chat.room.atomicCoroutineScope +import com.ably.chat.room.atomicRetry +import com.ably.chat.room.createMockLogger +import com.ably.chat.room.createRoomFeatureMocks +import io.mockk.coEvery +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert +import org.junit.Test + +/** + * Room lifecycle operations are atomic and exclusive operations: one operation must complete (whether that’s failure or success) before the next one may begin. + * Spec: CHA-RL7 + */ +class PrecedenceTest { + private val logger = createMockLogger() + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + /** + * 1. RETRY (CHA-RL7a1) - Internal operation. + * 2. RELEASE (CHA-RL7a2) - External operation. + * 3. ATTACH or DETACH (CHA-RL7a3) - External operation. + */ + @Suppress("LongMethod") + @Test + fun `(CHA-RL7a) If multiple operations are scheduled to run, they run as per LifecycleOperationPrecedence`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + val roomStatusChanges = mutableListOf() + statusLifecycle.onChange { + roomStatusChanges.add(it) + } + + val contributors = createRoomFeatureMocks("1234") + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + // Internal operation + coEvery { roomLifecycle["doRetry"](any()) } coAnswers { + delay(200) + statusLifecycle.setStatus(RoomStatus.Suspended) + statusLifecycle.setStatus(RoomStatus.Failed) + error("throwing error to avoid continuation getting stuck :( ") + } + // Attach operation + coEvery { roomLifecycle invokeNoArgs "doAttach" } coAnswers { + delay(500) + statusLifecycle.setStatus(RoomStatus.Attached) + DefaultRoomAttachmentResult() + } + // Detach operation + coEvery { roomLifecycle invokeNoArgs "doDetach" } coAnswers { + delay(200) + statusLifecycle.setStatus(RoomStatus.Detached) + } + // Release operation + coEvery { roomLifecycle invokeNoArgs "releaseChannels" } coAnswers { + delay(200) + statusLifecycle.setStatus(RoomStatus.Released) + } + withContext(Dispatchers.Default.limitedParallelism(1)) { + launch { + roomLifecycle.attach() + } + assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } // Get attach into processing + launch { + kotlin.runCatching { roomLifecycle.detach() } // Attach in process, Queue -> Detach + } + launch { + kotlin.runCatching { roomLifecycle.atomicRetry(contributors[0]) } // Attach in process, Queue -> Retry, Detach + } + launch { + kotlin.runCatching { roomLifecycle.attach() } // Attach in process, Queue -> Retry, Detach, Attach + } + + // Because of release, detach and attach won't be able to execute their operations + launch { + roomLifecycle.release() // Attach in process, Queue -> Retry, Release, Detach, Attach + } + } + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(6, roomStatusChanges.size) + Assert.assertEquals(RoomStatus.Attaching, roomStatusChanges[0].current) + Assert.assertEquals(RoomStatus.Attached, roomStatusChanges[1].current) + Assert.assertEquals(RoomStatus.Suspended, roomStatusChanges[2].current) + Assert.assertEquals(RoomStatus.Failed, roomStatusChanges[3].current) + Assert.assertEquals(RoomStatus.Releasing, roomStatusChanges[4].current) + Assert.assertEquals(RoomStatus.Released, roomStatusChanges[5].current) + + verify { + roomLifecycle["doRetry"](any()) + roomLifecycle invokeNoArgs "doAttach" + roomLifecycle invokeNoArgs "releaseChannels" + } + + verify(exactly = 0) { + roomLifecycle invokeNoArgs "doDetach" + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt new file mode 100644 index 0000000..5f70b4f --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/ReleaseTest.kt @@ -0,0 +1,381 @@ +package com.ably.chat.room.lifecycle + +import com.ably.chat.DefaultRoomLifecycle +import com.ably.chat.RoomLifecycleManager +import com.ably.chat.RoomStatus +import com.ably.chat.RoomStatusChange +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.createMockLogger +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 +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RL3 + */ +class ReleaseTest { + private val logger = createMockLogger() + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + @After + fun tearDown() { + unmockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + } + + @Test + fun `(CHA-RL3a) Release success when room is already in released state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Released) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL3b) If room is in detached state, room is immediately transitioned to released`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Detached) + } + val states = mutableListOf() + statusLifecycle.onChange { + states.add(it) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + Assert.assertEquals(1, states.size) + Assert.assertEquals(RoomStatus.Released, states[0].current) + Assert.assertEquals(RoomStatus.Detached, states[0].previous) + } + + @Test + fun `(CHA-RL3j) If room is in initialized state, room is immediately transitioned to released`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Initialized) + } + val states = mutableListOf() + statusLifecycle.onChange { + states.add(it) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + Assert.assertEquals(1, states.size) + Assert.assertEquals(RoomStatus.Released, states[0].current) + Assert.assertEquals(RoomStatus.Initialized, states[0].previous) + } + + @Test + fun `(CHA-RL3l) Release op should transition room into RELEASING state, transient timeouts should be cleared`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + val roomStatusChanges = mutableListOf() + statusLifecycle.onChange { + roomStatusChanges.add(it) + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, emptyList(), logger), recordPrivateCalls = true) + justRun { roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" } + + roomLifecycle.release() + Assert.assertEquals(RoomStatus.Releasing, roomStatusChanges[0].current) + Assert.assertEquals(RoomStatus.Released, roomStatusChanges[1].current) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + verify(exactly = 1) { + roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" + } + } + + @Test + fun `(CHA-RL3d) Release op should detach each contributor channel sequentially and room should be considered RELEASED`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + val capturedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + capturedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + + Assert.assertEquals(5, capturedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, capturedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[4].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL3e) If a one of the contributors is in failed state, release op continues for other contributors`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + val capturedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + capturedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks("1234") + + // Put typing contributor into failed state, so it won't be detached + contributors.first { it.channel.name.contains("typing") }.apply { + channel.setState(ChannelState.failed) + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(4, capturedChannels.size) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[3].name) + } + + @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(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + val roomEvents = mutableListOf() + statusLifecycle.onChange { + roomEvents.add(it) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + var failDetachTimes = 5 + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + if (--failDetachTimes >= 0) { + error("failed to detach channel") + } + val channel = firstArg() + channel.setState(ChannelState.detached) + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(2, roomEvents.size) + Assert.assertEquals(RoomStatus.Releasing, roomEvents[0].current) + Assert.assertEquals(RoomStatus.Released, roomEvents[1].current) + + // Channel release success on 6th call + coVerify(exactly = 6) { + roomLifecycle invokeNoArgs "doRelease" + } + } + + @Test + fun `(CHA-RL3g) Release op continues till all contributors enters either DETACHED or FAILED state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + var failDetachTimes = 5 + val capturedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + val channel = firstArg() + if (--failDetachTimes >= 0) { + channel.setState(listOf(ChannelState.attached, ChannelState.suspended).random()) + error("failed to detach channel") + } + channel.setState(listOf(ChannelState.detached, ChannelState.failed).random()) + capturedChannels.add(channel) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger), recordPrivateCalls = true) + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + + Assert.assertEquals(5, capturedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, capturedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[4].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + // Channel release success on 6th call + coVerify(exactly = 6) { + roomLifecycle invokeNoArgs "doRelease" + } + } + + @Test + fun `(CHA-RL3h) Upon channel release, underlying Realtime Channels are released from the core SDK prevent leakage`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + channel.setState(ChannelState.detached) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val releasedChannels = mutableListOf() + for (contributor in contributors) { + every { contributor.release() } answers { + releasedChannels.add(contributor.channel) + } + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + val result = kotlin.runCatching { roomLifecycle.release() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + + Assert.assertEquals(5, releasedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, releasedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", releasedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", releasedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", releasedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", releasedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", releasedChannels[4].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + for (contributor in contributors) { + verify(exactly = 1) { + contributor.release() + } + } + } + + @Test + fun `(CHA-RL3k) Release op should wait for existing operation as per (CHA-RL7)`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)).apply { + setStatus(RoomStatus.Attached) + } + + val roomEvents = mutableListOf() + + statusLifecycle.onChange { + roomEvents.add(it) + } + + mockkStatic(io.ably.lib.realtime.Channel::detachCoroutine) + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + channel.setState(ChannelState.detached) + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, createRoomFeatureMocks(), logger)) + + val roomAttached = Channel() + coEvery { + roomLifecycle.attach() + } coAnswers { + roomLifecycle.atomicCoroutineScope().async { + statusLifecycle.setStatus(RoomStatus.Attaching) + roomAttached.receive() + statusLifecycle.setStatus(RoomStatus.Attached) + } + } + + // ATTACH op started from separate coroutine + launch { roomLifecycle.attach() } + assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } + Assert.assertEquals(0, roomLifecycle.atomicCoroutineScope().pendingJobCount) // no queued jobs, one job running + assertWaiter { statusLifecycle.status == RoomStatus.Attaching } + + // Release op started from separate coroutine + val roomReleaseOpDeferred = async { roomLifecycle.release() } + assertWaiter { roomLifecycle.atomicCoroutineScope().pendingJobCount == 1 } // release op queued + Assert.assertEquals(RoomStatus.Attaching, statusLifecycle.status) + + // Finish room ATTACH + roomAttached.send(true) + + val result = kotlin.runCatching { roomReleaseOpDeferred.await() } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Released, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + Assert.assertEquals(4, roomEvents.size) + Assert.assertEquals(RoomStatus.Attaching, roomEvents[0].current) + Assert.assertEquals(RoomStatus.Attached, roomEvents[1].current) + Assert.assertEquals(RoomStatus.Releasing, roomEvents[2].current) + Assert.assertEquals(RoomStatus.Released, roomEvents[3].current) + + coVerify { roomLifecycle.attach() } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt b/chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt new file mode 100644 index 0000000..edfcafc --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/lifecycle/RetryTest.kt @@ -0,0 +1,246 @@ +package com.ably.chat.room.lifecycle + +import com.ably.chat.DefaultRoomLifecycle +import com.ably.chat.HttpStatusCode +import com.ably.chat.RoomLifecycleManager +import com.ably.chat.RoomStatus +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.createMockLogger +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 +import io.ably.lib.types.ErrorInfo +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RL5 + */ +class RetryTest { + private val logger = createMockLogger() + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + @After + fun tearDown() { + unmockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + } + + @Test + fun `(CHA-RL5a) Retry detaches all contributors except the one that's provided (based on underlying channel CHA-RL5a)`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coJustRun { any().attachCoroutine() } + + val capturedDetachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + capturedDetachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + val messagesContributor = contributors.first { it.featureName == "messages" } + messagesContributor.channel.setState(ChannelState.attached) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + val result = kotlin.runCatching { roomLifecycle.retry(messagesContributor) } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Attached, statusLifecycle.status) + + Assert.assertEquals(2, capturedDetachedChannels.size) + + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedDetachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedDetachedChannels[1].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL5c) If one of the contributor channel goes into failed state during channel windown (CHA-RL5a), then the room enters failed state and retry operation stops`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coJustRun { any().attachCoroutine() } + + coEvery { any().detachCoroutine() } coAnswers { + val channel = firstArg() + if (channel.name.contains("typing")) { + channel.setState(ChannelState.failed) + error("${channel.name} went into FAILED state") + } + } + + val contributors = createRoomFeatureMocks() + + val messagesContributor = contributors.first { it.featureName == "messages" } + messagesContributor.channel.setState(ChannelState.attached) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + val result = kotlin.runCatching { roomLifecycle.retry(messagesContributor) } + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL5c) If one of the contributor channel goes into failed state during Retry, then the room enters failed state and retry operation stops`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coJustRun { any().detachCoroutine() } + + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if (channel.name.contains("typing")) { + channel.setState(ChannelState.failed) + error("${channel.name} went into FAILED state") + } + } + + val contributors = createRoomFeatureMocks() + + val messagesContributor = contributors.first { it.featureName == "messages" } + messagesContributor.channel.setState(ChannelState.attached) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + roomLifecycle.retry(messagesContributor) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL5d) If all contributor channels goes into detached (except one provided in suspended state), provided contributor starts attach operation and waits for ATTACHED or FAILED state`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coJustRun { any().attachCoroutine() } + coJustRun { any().detachCoroutine() } + + val contributors = createRoomFeatureMocks() + val messagesContributor = contributors.first { it.featureName == "messages" } + + every { + messagesContributor.channel.once(eq(ChannelState.attached), any()) + } answers { + secondArg().onChannelStateChanged(null) + } + justRun { + messagesContributor.channel.once(eq(ChannelState.failed), any()) + } + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + val result = kotlin.runCatching { roomLifecycle.retry(messagesContributor) } + + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Attached, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + verify { + messagesContributor.channel.once(eq(ChannelState.attached), any()) + } + verify { + messagesContributor.channel.once(eq(ChannelState.failed), any()) + } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL5e) If, during the CHA-RL5d wait, the contributor channel becomes failed, then the room enters failed state and retry operation stops`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coJustRun { any().attachCoroutine() } + coJustRun { any().detachCoroutine() } + + val contributors = createRoomFeatureMocks() + val messagesContributor = contributors.first { it.featureName == "messages" } + + val errorInfo = ErrorInfo("Failed channel messages", HttpStatusCode.InternalServerError) + messagesContributor.channel.setState(ChannelState.failed, errorInfo) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + val result = kotlin.runCatching { roomLifecycle.retry(messagesContributor) } + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + Assert.assertEquals("Failed channel messages", exception.errorInfo.message) + Assert.assertEquals(RoomStatus.Failed, statusLifecycle.status) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL5f) If, during the CHA-RL5d wait, the contributor channel becomes ATTACHED, then attach operation continues for other contributors as per CHA-RL1e`() = runTest { + val statusLifecycle = spyk(DefaultRoomLifecycle(logger)) + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + val capturedAttachedChannels = mutableListOf() + coEvery { any().attachCoroutine() } coAnswers { + capturedAttachedChannels.add(firstArg()) + } + + val capturedDetachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + capturedDetachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + val messagesContributor = contributors.first { it.featureName == "messages" } + messagesContributor.channel.setState(ChannelState.attached) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, statusLifecycle, contributors, logger)) + + val result = kotlin.runCatching { roomLifecycle.retry(messagesContributor) } + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomStatus.Attached, statusLifecycle.status) + + Assert.assertEquals(2, capturedDetachedChannels.size) + + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedDetachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedDetachedChannels[1].name) + + Assert.assertEquals(5, capturedAttachedChannels.size) + + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedAttachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedAttachedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedAttachedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedAttachedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedAttachedChannels[4].name) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } +} diff --git a/detekt.yml b/detekt.yml index 95cd7e4..786d496 100644 --- a/detekt.yml +++ b/detekt.yml @@ -99,7 +99,7 @@ complexity: - 'with' LabeledExpression: active: true - ignoredLabels: [ ] + ignoredLabels: [ 'async', 'coroutineScope' ] LargeClass: active: true threshold: 600 @@ -952,7 +952,7 @@ style: UnderscoresInNumericLiterals: active: true acceptableLength: 4 - allowNonStandardGrouping: false + allowNonStandardGrouping: true UnnecessaryAbstractClass: active: false UnnecessaryAnnotationUseSiteTarget: @@ -990,7 +990,7 @@ style: - 'Preview' UnusedPrivateProperty: active: true - allowedNames: '_|ignored|expected|serialVersionUID' + allowedNames: '_|ignored|expected|serialVersionUID|logger' UseAnyOrNoneInsteadOfFind: active: false UseArrayLiteralsInAnnotations: 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 2af9373..18f1390 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -58,6 +58,7 @@ import io.ably.lib.types.ClientOptions import java.util.UUID import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking val randomClientId = UUID.randomUUID().toString() @@ -88,10 +89,12 @@ class MainActivity : ComponentActivity() { @Composable fun App(chatClient: ChatClient) { var showPopup by remember { mutableStateOf(false) } - val room = chatClient.rooms.get( - Settings.ROOM_ID, - RoomOptions(typing = TypingOptions(), presence = PresenceOptions(), reactions = RoomReactionsOptions), - ) + val room = runBlocking { + chatClient.rooms.get( + Settings.ROOM_ID, + RoomOptions(typing = TypingOptions(), presence = PresenceOptions(), reactions = RoomReactionsOptions), + ) + } val coroutineScope = rememberCoroutineScope() val currentlyTyping by typingUsers(room.typing) 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 index cfc52c2..f82e2fc 100644 --- a/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt +++ b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt @@ -22,17 +22,21 @@ 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.RoomOptions import com.ably.chat.Subscription import com.ably.chat.example.Settings import com.google.gson.JsonObject import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking -@SuppressWarnings("LongMethod") +@Suppress("LongMethod") @Composable fun PresencePopup(chatClient: ChatClient, onDismiss: () -> Unit) { var members by remember { mutableStateOf(listOf()) } val coroutineScope = rememberCoroutineScope() - val presence = chatClient.rooms.get(Settings.ROOM_ID).presence + val presence = runBlocking { + chatClient.rooms.get(Settings.ROOM_ID, RoomOptions.default).presence + } DisposableEffect(Unit) { var subscription: Subscription? = null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cac536c..2395ffe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] ably-chat = "0.0.1" -ably = "1.2.43" +ably = "1.2.45" desugar-jdk-libs = "2.1.2" junit = "4.13.2" agp = "8.5.2" @@ -18,7 +18,7 @@ lifecycle-runtime-ktx = "2.8.4" activity-compose = "1.9.1" compose-bom = "2024.06.00" gson = "2.11.0" -mockk = "1.13.12" +mockk = "1.13.13" coroutine = "1.9.0" build-config = "5.4.0" ktor = "3.0.1"