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 c58e3d79..fc5e69a3 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatApi.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatApi.kt @@ -1,7 +1,6 @@ package com.ably.chat import com.google.gson.JsonElement -import com.google.gson.JsonObject import io.ably.lib.types.AblyException import io.ably.lib.types.AsyncHttpPaginatedResponse import io.ably.lib.types.ErrorInfo @@ -13,7 +12,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine private const val API_PROTOCOL_VERSION = 3 private const val PROTOCOL_VERSION_PARAM_NAME = "v" -private const val RESERVED_ABLY_CHAT_KEY = "ably-chat" private val apiProtocolParam = Param(PROTOCOL_VERSION_PARAM_NAME, API_PROTOCOL_VERSION.toString()) internal class ChatApi( @@ -35,85 +33,115 @@ internal class ChatApi( method = "GET", params = params, ) { - val latestActionName = it.requireJsonObject().get("action")?.asString - val latestAction = latestActionName?.let { name -> messageActionNameToAction[name] } - + val messageJsonObject = it.requireJsonObject() + val latestAction = messageJsonObject.get(MessageProperty.Action)?.asString?.let { name -> messageActionNameToAction[name] } + val operation = messageJsonObject.getAsJsonObject(MessageProperty.Operation) latestAction?.let { action -> Message( - serial = it.requireString("serial"), - clientId = it.requireString("clientId"), - roomId = it.requireString("roomId"), - text = it.requireString("text"), - createdAt = it.requireLong("createdAt"), - metadata = it.asJsonObject.get("metadata"), - headers = it.asJsonObject.get("headers")?.toMap() ?: mapOf(), + serial = messageJsonObject.requireString(MessageProperty.Serial), + clientId = messageJsonObject.requireString(MessageProperty.ClientId), + roomId = messageJsonObject.requireString(MessageProperty.RoomId), + text = messageJsonObject.requireString(MessageProperty.Text), + createdAt = messageJsonObject.requireLong(MessageProperty.CreatedAt), + metadata = messageJsonObject.getAsJsonObject(MessageProperty.Metadata) ?: MessageMetadata(), + headers = messageJsonObject.get(MessageProperty.Headers)?.toMap() ?: mapOf(), action = action, + version = messageJsonObject.requireString(MessageProperty.Version), + timestamp = messageJsonObject.requireLong(MessageProperty.Timestamp), + operation = buildMessageOperation(operation), ) } } } /** - * Send message to the Chat Backend - * - * @return sent message instance + * Spec: CHA-M3 */ suspend fun sendMessage(roomId: String, params: SendMessageParams): Message { - validateSendMessageParams(params) - - val body = JsonObject().apply { - addProperty("text", params.text) - // (CHA-M3b) - params.headers?.let { - add("headers", it.toJson()) - } - // (CHA-M3b) - params.metadata?.let { - add("metadata", it) - } - } + val body = params.toJsonObject() // CHA-M3b return makeAuthorizedRequest( "/chat/v2/rooms/$roomId/messages", "POST", body, )?.let { - // (CHA-M3a) + val serial = it.requireString(MessageProperty.Serial) + val createdAt = it.requireLong(MessageProperty.CreatedAt) + // CHA-M3a Message( - serial = it.requireString("serial"), + serial = serial, clientId = clientId, roomId = roomId, text = params.text, - createdAt = it.requireLong("createdAt"), - metadata = params.metadata, + createdAt = createdAt, + metadata = params.metadata ?: MessageMetadata(), headers = params.headers ?: mapOf(), action = MessageAction.MESSAGE_CREATE, + version = serial, + timestamp = createdAt, + operation = null, ) - } ?: throw AblyException.fromErrorInfo(ErrorInfo("Send message endpoint returned empty value", HttpStatusCode.InternalServerError)) + } ?: throw serverError("Send message endpoint returned empty value") // CHA-M3e } - private fun validateSendMessageParams(params: SendMessageParams) { - // (CHA-M3c) - if ((params.metadata as? JsonObject)?.has(RESERVED_ABLY_CHAT_KEY) == true) { - throw AblyException.fromErrorInfo( - ErrorInfo( - "Metadata contains reserved 'ably-chat' key", - HttpStatusCode.BadRequest, - ErrorCode.InvalidRequestBody.code, - ), + /** + * Spec: CHA-M8 + */ + suspend fun updateMessage(message: Message, params: UpdateMessageParams): Message { + val body = params.toJsonObject() + // CHA-M8c + return makeAuthorizedRequest( + "/chat/v2/rooms/${message.roomId}/messages/${message.serial}", + "PUT", + body, + )?.let { + val version = it.requireString(MessageProperty.Version) + val timestamp = it.requireLong(MessageProperty.Timestamp) + // CHA-M8b + Message( + serial = message.serial, + clientId = clientId, + roomId = message.roomId, + text = params.message.text, + createdAt = message.createdAt, + metadata = params.message.metadata ?: MessageMetadata(), + headers = params.message.headers ?: mapOf(), + action = MessageAction.MESSAGE_UPDATE, + version = version, + timestamp = timestamp, + operation = buildMessageOperation(clientId, params.description, params.metadata), ) - } + } ?: throw serverError("Update message endpoint returned empty value") // CHA-M8d + } + + /** + * Spec: CHA-M9 + */ + suspend fun deleteMessage(message: Message, params: DeleteMessageParams): Message { + val body = params.toJsonObject() - // (CHA-M3d) - if (params.headers?.keys?.any { it.startsWith(RESERVED_ABLY_CHAT_KEY) } == true) { - throw AblyException.fromErrorInfo( - ErrorInfo( - "Headers contains reserved key with reserved 'ably-chat' prefix", - HttpStatusCode.BadRequest, - ErrorCode.InvalidRequestBody.code, - ), + return makeAuthorizedRequest( + "/chat/v2/rooms/${message.roomId}/messages/${message.serial}/delete", + "POST", + body, + )?.let { + val version = it.requireString(MessageProperty.Version) + val timestamp = it.requireLong(MessageProperty.Timestamp) + // CHA-M9b + Message( + serial = message.serial, + clientId = clientId, + roomId = message.roomId, + text = message.text, + createdAt = message.createdAt, + metadata = message.metadata, + headers = message.headers, + action = MessageAction.MESSAGE_DELETE, + version = version, + timestamp = timestamp, + operation = buildMessageOperation(clientId, params.description, params.metadata), ) - } + } ?: throw serverError("Delete message endpoint returned empty value") // CHA-M9c } /** @@ -125,7 +153,7 @@ internal class ChatApi( connections = it.requireInt("connections"), presenceMembers = it.requireInt("presenceMembers"), ) - } ?: throw AblyException.fromErrorInfo(ErrorInfo("Occupancy endpoint returned empty value", HttpStatusCode.InternalServerError)) + } ?: throw serverError("Occupancy endpoint returned empty value") } private suspend fun makeAuthorizedRequest( diff --git a/chat-android/src/main/java/com/ably/chat/ChatClient.kt b/chat-android/src/main/java/com/ably/chat/ChatClient.kt index 08928763..8dbb1835 100644 --- a/chat-android/src/main/java/com/ably/chat/ChatClient.kt +++ b/chat-android/src/main/java/com/ably/chat/ChatClient.kt @@ -1,5 +1,3 @@ -@file:Suppress("NotImplementedDeclaration") - package com.ably.chat import io.ably.lib.realtime.AblyRealtime diff --git a/chat-android/src/main/java/com/ably/chat/EventTypes.kt b/chat-android/src/main/java/com/ably/chat/EventTypes.kt index 666dc3de..c869c9ff 100644 --- a/chat-android/src/main/java/com/ably/chat/EventTypes.kt +++ b/chat-android/src/main/java/com/ably/chat/EventTypes.kt @@ -19,7 +19,7 @@ enum class MessageEventType(val eventName: String) { /** * Realtime chat message names. */ -object PubSubMessageNames { +object PubSubEventName { /** Represents a regular chat message. */ const val ChatMessage = "chat.message" } @@ -42,6 +42,12 @@ internal val messageActionNameToAction = mapOf( "message.summary" to MessageAction.MESSAGE_SUMMARY, ) +internal val messageActionToEventType = mapOf( + MessageAction.MESSAGE_CREATE to MessageEventType.Created, + MessageAction.MESSAGE_UPDATE to MessageEventType.Updated, + MessageAction.MESSAGE_DELETE to MessageEventType.Deleted, +) + /** * Enum representing presence events. */ diff --git a/chat-android/src/main/java/com/ably/chat/Message.kt b/chat-android/src/main/java/com/ably/chat/Message.kt index 019ad81b..2f3e17f5 100644 --- a/chat-android/src/main/java/com/ably/chat/Message.kt +++ b/chat-android/src/main/java/com/ably/chat/Message.kt @@ -1,5 +1,7 @@ package com.ably.chat +import com.google.gson.JsonObject +import io.ably.lib.types.Message import io.ably.lib.types.MessageAction /** @@ -18,6 +20,7 @@ typealias MessageMetadata = Metadata data class Message( /** * The unique identifier of the message. + * Spec: CHA-M2d */ val serial: String, @@ -53,7 +56,7 @@ data class Message( * Do not use metadata for authoritative information. There is no server-side * validation. When reading the metadata treat it like user input. */ - val metadata: MessageMetadata?, + val metadata: MessageMetadata, /** * The headers of a chat message. Headers enable attaching extra info to a message, @@ -72,6 +75,79 @@ data class Message( /** * The latest action of the message. This can be used to determine if the message was created, updated, or deleted. + * Spec: CHA-M10 */ val action: MessageAction, + + /** + * A unique identifier for the latest version of this message. + * Spec: CHA-M10a + */ + val version: String, + + /** + * The timestamp at which this version was updated, deleted, or created. + */ + val timestamp: Long, + + /** + * The details of the operation that modified the message. This is only set for update and delete actions. It contains + * information about the operation: the clientId of the user who performed the operation, a description, and metadata. + */ + val operation: Message.Operation? = null, ) + +internal fun buildMessageOperation(jsonObject: JsonObject?): Message.Operation? { + if (jsonObject == null) { + return null + } + val operation = Message.Operation() + if (jsonObject.has(MessageOperationProperty.ClientId)) { + operation.clientId = jsonObject.get(MessageOperationProperty.ClientId).asString + } + if (jsonObject.has(MessageOperationProperty.Description)) { + operation.description = jsonObject.get(MessageOperationProperty.Description).asString + } + if (jsonObject.has(MessageOperationProperty.Metadata)) { + val metadataObject = jsonObject.getAsJsonObject(MessageOperationProperty.Metadata) + operation.metadata = mutableMapOf() + for ((key, value) in metadataObject.entrySet()) { + operation.metadata[key] = value.asString + } + } + return operation +} + +internal fun buildMessageOperation(clientId: String, description: String?, metadata: Map?): Message.Operation { + val operation = Message.Operation() + operation.clientId = clientId + operation.description = description + operation.metadata = metadata + return operation +} + +/** + * MessageProperty object representing the properties of a message. + */ +internal object MessageProperty { + const val Serial = "serial" + const val ClientId = "clientId" + const val RoomId = "roomId" + const val Text = "text" + const val CreatedAt = "createdAt" + const val Metadata = "metadata" + const val Headers = "headers" + const val Action = "action" + const val Version = "version" + const val Timestamp = "timestamp" + const val Operation = "operation" +} + +/** + * MessageOperationProperty object representing the properties of a message operation. + */ +internal object MessageOperationProperty { + const val ClientId = "clientId" + const val Description = "description" + const val Metadata = "metadata" +} 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 5bd41262..68a44d98 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -1,5 +1,3 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") - package com.ably.chat import com.ably.chat.OrderBy.NewestFirst @@ -7,7 +5,6 @@ import com.google.gson.JsonObject import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener -import io.ably.lib.types.MessageAction import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CompletableDeferred import io.ably.lib.realtime.Channel as AblyRealtimeChannel @@ -61,6 +58,7 @@ interface Messages : EmitsDiscontinuities { * Note: that the suspending function may resolve before OR after the message is received * from the realtime channel. This means you may see the message that was just * sent in a callback to `subscribe` before the function resolves. + * Spec: CHA-M3 * * @param text The text of the message. See [SendMessageParams.text] * @param metadata Optional metadata of the message. See [SendMessageParams.metadata] @@ -70,6 +68,41 @@ interface Messages : EmitsDiscontinuities { */ suspend fun send(text: String, metadata: MessageMetadata? = null, headers: MessageHeaders? = null): Message + /** + * Update a message in the chat room. + * + * This method uses the Ably Chat API REST endpoint for updating messages. + * It creates a new message with the same serial and a new version. + * The original message is not modified. + * Spec: CHA-M8 + * + * @param updatedMessage The updated copy of the message created using the `message.copy` method. + * @param operationDescription Optional description for the update action. + * @param operationMetadata Optional metadata for the update action. + * @returns updated message. + */ + suspend fun update(updatedMessage: Message, operationDescription: String? = null, operationMetadata: OperationMetadata? = null): Message + + /** + * Delete a message in the chat room. + * + * This method uses the Ably Chat API REST endpoint for deleting messages. + * It performs a `soft` delete, meaning the message is marked as deleted. + * + * Should you wish to restore a deleted message, and providing you have the appropriate permissions, + * you can simply send an update to the original message. + * Note: This is subject to change in future versions, whereby a new permissions model will be introduced + * and a deleted message may not be restorable in this way. + * Spec: CHA-M9 + * + * @returns when the message is deleted. + * @param message - The message to delete. + * @param operationDescription - Optional description for the delete action. + * @param operationMetadata - Optional metadata for the delete action. + * @return A promise that resolves to the deleted message. + */ + suspend fun delete(message: Message, operationDescription: String? = null, operationMetadata: OperationMetadata? = null): Message + /** * An interface for listening to new messaging event */ @@ -173,6 +206,62 @@ internal data class SendMessageParams( val headers: MessageHeaders? = null, ) +internal fun SendMessageParams.toJsonObject(): JsonObject { + return JsonObject().apply { + addProperty("text", text) + // CHA-M3b + headers?.let { add("headers", it.toJson()) } + metadata?.let { add("metadata", it) } + } +} + +/** + * Params for updating a message. It accepts all parameters that sending a + * message accepts. Also accepts `description` and `metadata` for the update action. + * + * Note that updating a message creates a new message with original serial and a new version. + */ +internal data class UpdateMessageParams( + val message: SendMessageParams, + /** + * Optional description for the message action. + */ + val description: String?, + /** + * Optional metadata that will be added to the update action. Defaults to empty. + */ + val metadata: OperationMetadata?, +) + +internal fun UpdateMessageParams.toJsonObject(): JsonObject { + return JsonObject().apply { + add("message", message.toJsonObject()) + description?.let { addProperty(MessageOperationProperty.Description, it) } + metadata?.let { add(MessageOperationProperty.Metadata, it.toJson()) } + } +} + +/** + * Parameters for deleting a message. + */ +internal data class DeleteMessageParams( + /** + * Optional description for the message action. + */ + val description: String?, + /** + * Optional metadata that will be added to the delete action. Defaults to empty. + */ + val metadata: OperationMetadata?, +) + +internal fun DeleteMessageParams.toJsonObject(): JsonObject { + return JsonObject().apply { + description?.let { addProperty(MessageOperationProperty.Description, it) } + metadata?.let { add(MessageOperationProperty.Metadata, it.toJson()) } + } +} + interface MessagesSubscription : Subscription { /** * (CHA-M5j) @@ -262,9 +351,8 @@ internal class DefaultMessages( override fun subscribe(listener: Messages.Listener): MessagesSubscription { val messageListener = PubSubMessageListener { val pubSubMessage = it ?: throw clientError("Got empty pubsub channel message") - - // Ignore any action that is not message.create - if (pubSubMessage.action != MessageAction.MESSAGE_CREATE) return@PubSubMessageListener + val eventType = messageActionToEventType[pubSubMessage.action] + ?: throw clientError("Received Unknown message action ${pubSubMessage.action}") val data = parsePubSubMessageData(pubSubMessage.data) val chatMessage = Message( @@ -273,15 +361,18 @@ internal class DefaultMessages( clientId = pubSubMessage.clientId, serial = pubSubMessage.serial, text = data.text, - metadata = data.metadata, + metadata = data.metadata ?: MessageMetadata(), headers = pubSubMessage.extras.asJsonObject().get("headers")?.toMap() ?: mapOf(), - action = MessageAction.MESSAGE_CREATE, + action = pubSubMessage.action, + version = pubSubMessage.version, + timestamp = pubSubMessage.timestamp, + operation = pubSubMessage.operation, ) - listener.onEvent(MessageEvent(type = MessageEventType.Created, message = chatMessage)) + listener.onEvent(MessageEvent(type = eventType, message = chatMessage)) } channelSerialMap[messageListener] = deferredChannelSerial // (CHA-M4d) - channel.subscribe(PubSubMessageNames.ChatMessage, messageListener) + channel.subscribe(PubSubEventName.ChatMessage, messageListener) // (CHA-M5) setting subscription point if (channel.state == ChannelState.attached) { channelSerialMap[messageListener] = CompletableDeferred(requireChannelSerial()) @@ -292,7 +383,7 @@ internal class DefaultMessages( roomId = roomId, subscription = { channelSerialMap.remove(messageListener) - channel.unsubscribe(PubSubMessageNames.ChatMessage, messageListener) + channel.unsubscribe(PubSubEventName.ChatMessage, messageListener) }, fromSerialProvider = { channelSerialMap[messageListener] @@ -307,11 +398,38 @@ internal class DefaultMessages( QueryOptions(start, end, limit, orderBy), ) - override suspend fun send(text: String, metadata: MessageMetadata?, headers: MessageHeaders?): Message = chatApi.sendMessage( - roomId, - SendMessageParams(text, metadata, headers), + override suspend fun send(text: String, metadata: MessageMetadata?, headers: MessageHeaders?): Message = + chatApi.sendMessage( + roomId, + SendMessageParams(text, metadata, headers), + ) + + override suspend fun update( + updatedMessage: Message, + operationDescription: String?, + operationMetadata: OperationMetadata?, + ): Message = chatApi.updateMessage( + updatedMessage, + UpdateMessageParams( + message = SendMessageParams(updatedMessage.text, updatedMessage.metadata, updatedMessage.headers), + description = operationDescription, + metadata = operationMetadata, + ), ) + override suspend fun delete( + message: Message, + operationDescription: String?, + operationMetadata: OperationMetadata?, + ): Message = + chatApi.deleteMessage( + message, + DeleteMessageParams( + description = operationDescription, + metadata = operationMetadata, + ), + ) + private fun requireChannelSerial(): String { return channel.properties.channelSerial ?: throw clientError("Channel has been attached, but channelSerial is not defined") @@ -340,6 +458,6 @@ private fun parsePubSubMessageData(data: Any): PubSubMessageData { } return PubSubMessageData( text = data.requireString("text"), - metadata = data.get("metadata"), + metadata = data.getAsJsonObject("metadata"), ) } diff --git a/chat-android/src/main/java/com/ably/chat/Metadata.kt b/chat-android/src/main/java/com/ably/chat/Metadata.kt index dd57ffb5..b6d5ae19 100644 --- a/chat-android/src/main/java/com/ably/chat/Metadata.kt +++ b/chat-android/src/main/java/com/ably/chat/Metadata.kt @@ -1,6 +1,6 @@ package com.ably.chat -import com.google.gson.JsonElement +import com.google.gson.JsonObject /** * Metadata is a map of extra information that can be attached to chat @@ -15,4 +15,4 @@ import com.google.gson.JsonElement * The key `ably-chat` is reserved and cannot be used. Ably may populate * this with different values in the future. */ -typealias Metadata = JsonElement +typealias Metadata = JsonObject 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 a61d5f76..c7174dec 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -1,4 +1,4 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") +@file:Suppress("StringLiteralDuplication") package com.ably.chat diff --git a/chat-android/src/main/java/com/ably/chat/OperationMetadata.kt b/chat-android/src/main/java/com/ably/chat/OperationMetadata.kt new file mode 100644 index 00000000..b976a227 --- /dev/null +++ b/chat-android/src/main/java/com/ably/chat/OperationMetadata.kt @@ -0,0 +1,11 @@ +package com.ably.chat + +/** + * The type for metadata contained in the operations field of a chat message. + * This is a key-value pair where the key is a string, and the value is a string, it represents the metadata supplied + * to a message update or deletion request. + * + * Do not use metadata for authoritative information. There is no server-side + * validation. When reading the metadata, treat it like user input. + */ +typealias OperationMetadata = Map 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 79fcfe44..289d3937 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -1,4 +1,4 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") +@file:Suppress("StringLiteralDuplication") package com.ably.chat 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 2cd61f25..a5e8297d 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -1,5 +1,3 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") - package com.ably.chat import io.ably.lib.types.ErrorInfo 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 41f44f80..5cfafbf2 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -1,4 +1,4 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") +@file:Suppress("StringLiteralDuplication") package com.ably.chat @@ -153,7 +153,7 @@ internal class DefaultRoomReactions( type = data.requireString("type"), createdAt = pubSubMessage.timestamp, clientId = pubSubMessage.clientId, - metadata = data.get("metadata"), + metadata = data.getAsJsonObject("metadata"), headers = pubSubMessage.extras?.asJsonObject()?.get("headers")?.toMap() ?: mapOf(), isSelf = pubSubMessage.clientId == room.clientId, ) 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 d2e76c62..4465a8fa 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -1,4 +1,4 @@ -@file:Suppress("StringLiteralDuplication", "NotImplementedDeclaration") +@file:Suppress("StringLiteralDuplication") package com.ably.chat diff --git a/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt index 9dad5542..ff09feba 100644 --- a/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt +++ b/chat-android/src/test/java/com/ably/chat/ChatApiTest.kt @@ -27,12 +27,15 @@ class ChatApiTest { listOf( JsonObject().apply { addProperty("foo", "bar") - addProperty("serial", "timeserial") - addProperty("roomId", "roomId") - addProperty("clientId", "clientId") - addProperty("text", "hello") - addProperty("createdAt", 1_000_000) - addProperty("action", "message.create") + add(MessageProperty.Metadata, JsonObject()) + addProperty(MessageProperty.Serial, "timeserial") + addProperty(MessageProperty.RoomId, "roomId") + addProperty(MessageProperty.ClientId, "clientId") + addProperty(MessageProperty.Text, "hello") + addProperty(MessageProperty.CreatedAt, 1_000_000) + addProperty(MessageProperty.Action, "message.create") + addProperty(MessageProperty.Version, "timeserial") + addProperty(MessageProperty.Timestamp, 1_000_000) }, ), ) @@ -47,9 +50,11 @@ class ChatApiTest { clientId = "clientId", text = "hello", createdAt = 1_000_000L, - metadata = null, + metadata = MessageMetadata(), headers = mapOf(), action = MessageAction.MESSAGE_CREATE, + version = "timeserial", + timestamp = 1_000_000L, ), ), messages.items, @@ -66,7 +71,7 @@ class ChatApiTest { listOf( JsonObject().apply { addProperty("foo", "bar") - addProperty("action", "message.create") + addProperty(MessageProperty.Action, "message.create") }, ), ) @@ -87,8 +92,8 @@ class ChatApiTest { realtime, JsonObject().apply { addProperty("foo", "bar") - addProperty("serial", "timeserial") - addProperty("createdAt", 1_000_000) + addProperty(MessageProperty.Serial, "timeserial") + addProperty(MessageProperty.CreatedAt, 1_000_000) }, ) @@ -102,8 +107,10 @@ class ChatApiTest { text = "hello", createdAt = 1_000_000L, headers = mapOf(), - metadata = null, + metadata = MessageMetadata(), action = MessageAction.MESSAGE_CREATE, + version = "timeserial", + timestamp = 1_000_000L, ), message, ) @@ -118,7 +125,7 @@ class ChatApiTest { realtime, JsonObject().apply { addProperty("foo", "bar") - addProperty("createdAt", 1_000_000) + addProperty(MessageProperty.CreatedAt, 1_000_000) }, ) 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 5ce10083..fdf139c2 100644 --- a/chat-android/src/test/java/com/ably/chat/MessagesTest.kt +++ b/chat-android/src/test/java/com/ably/chat/MessagesTest.kt @@ -54,8 +54,8 @@ class MessagesTest { mockSendMessageApiResponse( realtimeClient, JsonObject().apply { - addProperty("serial", "abcdefghij@1672531200000-123") - addProperty("createdAt", 1_000_000) + addProperty(MessageProperty.Serial, "abcdefghij@1672531200000-123") + addProperty(MessageProperty.CreatedAt, 1_000_000) }, roomId = "room1", ) @@ -76,6 +76,8 @@ class MessagesTest { metadata = JsonObject().apply { addProperty("meta", "data") }, headers = mapOf("foo" to "bar"), action = MessageAction.MESSAGE_CREATE, + version = "abcdefghij@1672531200000-123", + timestamp = 1_000_000L, ), sentMessage, ) @@ -104,6 +106,7 @@ class MessagesTest { PubSubMessage().apply { data = JsonObject().apply { addProperty("text", "some text") + add("metadata", JsonObject()) } serial = "abcdefghij@1672531200000-123" clientId = "clientId" @@ -120,6 +123,7 @@ class MessagesTest { }, ) action = MessageAction.MESSAGE_CREATE + version = "abcdefghij@1672531200000-123" }, ) @@ -133,9 +137,11 @@ class MessagesTest { clientId = "clientId", serial = "abcdefghij@1672531200000-123", text = "some text", - metadata = null, + metadata = MessageMetadata(), headers = mapOf("foo" to "bar"), action = MessageAction.MESSAGE_CREATE, + version = "abcdefghij@1672531200000-123", + timestamp = 1000L, ), messageEvent.message, ) @@ -224,38 +230,6 @@ class MessagesTest { verify(exactly = 2) { listener1.onEvent(any()) } verify(exactly = 1) { listener2.onEvent(any()) } } - - /** - * @spec CHA-M3d - */ - @Test - fun `should throw exception if headers contains ably-chat prefix`() = runTest { - val exception = assertThrows(AblyException::class.java) { - runBlocking { - messages.send( - text = "lala", - headers = mapOf("ably-chat-foo" to "bar"), - ) - } - } - assertEquals(40_001, exception.errorInfo.code) - } - - /** - * @spec CHA-M3c - */ - @Test - fun `should throw exception if metadata contains ably-chat key`() = runTest { - val exception = assertThrows(AblyException::class.java) { - runBlocking { - messages.send( - text = "lala", - metadata = mapOf("ably-chat" to "data").toJson(), - ) - } - } - assertEquals(40_001, exception.errorInfo.code) - } } private val Channel.channelMulticaster: ChannelBase.MessageListener @@ -267,6 +241,7 @@ private val Channel.channelMulticaster: ChannelBase.MessageListener private fun buildDummyPubSubMessage() = PubSubMessage().apply { data = JsonObject().apply { addProperty("text", "dummy text") + add("metadata", JsonObject()) } serial = "abcdefghij@1672531200000-123" clientId = "dummy" @@ -276,4 +251,5 @@ private fun buildDummyPubSubMessage() = PubSubMessage().apply { JsonObject().apply {}, ) action = MessageAction.MESSAGE_CREATE + version = "abcdefghij@1672531200000-123" } 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 7a4a7436..89bf86b1 100644 --- a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt +++ b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt @@ -72,6 +72,7 @@ class RoomReactionsTest { PubSubMessage().apply { data = JsonObject().apply { addProperty("type", "like") + add("metadata", JsonObject()) } clientId = "clientId" timestamp = 1000L @@ -95,7 +96,7 @@ class RoomReactionsTest { type = "like", createdAt = 1000L, clientId = "clientId", - metadata = null, + metadata = MessageMetadata(), headers = mapOf("foo" to "bar"), isSelf = false, ), diff --git a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt deleted file mode 100644 index 8a19df34..00000000 --- a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.ably.chat - -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.BeforeClass -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(), roomOptions) - room.attach() - val members = room.presence.get() - assertEquals(0, members.size) - } - - @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(), roomOptions) - room.attach() - room.presence.enter() - val members = room.presence.get() - assertEquals(1, members.size) - assertEquals("sandbox-client", members.first().clientId) - } - - @Test - fun `should return typing indication for client`() = runTest { - val chatClient1 = sandbox.createSandboxChatClient("client1") - val chatClient2 = sandbox.createSandboxChatClient("client2") - val roomId = UUID.randomUUID().toString() - val roomOptions = RoomOptions(typing = TypingOptions(timeoutMs = 10_000)) - val chatClient1Room = chatClient1.rooms.get(roomId, roomOptions) - chatClient1Room.attach() - val chatClient2Room = chatClient2.rooms.get(roomId, roomOptions) - chatClient2Room.attach() - - val deferredValue = CompletableDeferred() - chatClient2Room.typing.subscribe { - deferredValue.complete(it) - } - chatClient1Room.typing.start() - val typingEvent = deferredValue.await() - assertEquals(setOf("client1"), typingEvent.currentlyTyping) - assertEquals(setOf("client1"), chatClient2Room.typing.get()) - } - - @Test - fun `should return occupancy for the client`() = runTest { - val chatClient = sandbox.createSandboxChatClient("client1") - val roomId = UUID.randomUUID().toString() - val roomOptions = RoomOptions(occupancy = OccupancyOptions()) - - val chatClientRoom = chatClient.rooms.get(roomId, roomOptions) - - val firstOccupancyEvent = CompletableDeferred() - chatClientRoom.occupancy.subscribeOnce { - firstOccupancyEvent.complete(it) - } - - chatClientRoom.attach() - assertEquals(OccupancyEvent(1, 0), firstOccupancyEvent.await()) - } - - @Test - fun `should observe connection status`() = runTest { - val chatClient = sandbox.createSandboxChatClient() - val connectionStatusChange = CompletableDeferred() - chatClient.connection.onStatusChange { - if (it.current == ConnectionStatus.Connected) connectionStatusChange.complete(it) - } - assertEquals( - ConnectionStatusChange( - current = ConnectionStatus.Connected, - previous = ConnectionStatus.Connecting, - error = null, - retryIn = 0, - ), - connectionStatusChange.await(), - ) - } - - @Test - fun `should observe room reactions`() = runTest { - val chatClient = sandbox.createSandboxChatClient() - val roomId = UUID.randomUUID().toString() - val roomOptions = RoomOptions(reactions = RoomReactionsOptions()) - - val room = chatClient.rooms.get(roomId, roomOptions) - room.attach() - - val reactionEvent = CompletableDeferred() - - room.reactions.subscribe { reactionEvent.complete(it) } - - room.reactions.send("heart") - - assertEquals( - "heart", - reactionEvent.await().type, - ) - } - - @Test - fun `should be able to send and retrieve messages without room features`() = runTest { - val chatClient = sandbox.createSandboxChatClient() - val roomId = UUID.randomUUID().toString() - - val room = chatClient.rooms.get(roomId) - - room.attach() - - val messageEvent = CompletableDeferred() - - room.messages.subscribe { messageEvent.complete(it) } - room.messages.send("hello") - - assertEquals( - "hello", - messageEvent.await().message.text, - ) - } - - @Test - fun `should be able to send and retrieve messages with all room features enabled`() = runTest { - val chatClient = sandbox.createSandboxChatClient() - val roomId = UUID.randomUUID().toString() - - val room = chatClient.rooms.get(roomId, RoomOptions.default) - - room.attach() - - val messageEvent = CompletableDeferred() - - room.messages.subscribe { messageEvent.complete(it) } - room.messages.send("hello") - - assertEquals( - "hello", - messageEvent.await().message.text, - ) - } - - @Test - fun `should be able to send and retrieve messages from history`() = runTest { - val chatClient = sandbox.createSandboxChatClient() - val roomId = UUID.randomUUID().toString() - - val room = chatClient.rooms.get(roomId) - - room.attach() - - room.messages.send("hello") - - lateinit var messages: List - - assertWaiter { - messages = room.messages.get().items - messages.isNotEmpty() - } - - assertEquals(1, messages.size) - assertEquals("hello", messages.first().text) - assertEquals("sandbox-client", messages.first().clientId) - } - - companion object { - - private lateinit var sandbox: Sandbox - - @JvmStatic - @BeforeClass - fun setUp() = runTest { - sandbox = Sandbox.createInstance() - } - } -} diff --git a/chat-android/src/test/java/com/ably/chat/integration/ConnectionIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/ConnectionIntegrationTest.kt new file mode 100644 index 00000000..b2f1eadc --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/ConnectionIntegrationTest.kt @@ -0,0 +1,40 @@ +package com.ably.chat.integration + +import com.ably.chat.ConnectionStatus +import com.ably.chat.ConnectionStatusChange +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test + +class ConnectionIntegrationTest { + + @Test + fun `should observe connection status`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val connectionStatusChange = CompletableDeferred() + chatClient.connection.onStatusChange { + if (it.current == ConnectionStatus.Connected) connectionStatusChange.complete(it) + } + assertEquals( + ConnectionStatusChange( + current = ConnectionStatus.Connected, + previous = ConnectionStatus.Connecting, + error = null, + retryIn = 0, + ), + connectionStatusChange.await(), + ) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/integration/MessagesIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/MessagesIntegrationTest.kt new file mode 100644 index 00000000..74274452 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/MessagesIntegrationTest.kt @@ -0,0 +1,260 @@ +package com.ably.chat.integration + +import com.ably.chat.Message +import com.ably.chat.MessageEvent +import com.ably.chat.MessageMetadata +import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatus +import com.ably.chat.assertWaiter +import io.ably.lib.types.MessageAction +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test + +class MessagesIntegrationTest { + + /** + * Spec: CHA-M3, CHA-M4 + */ + @Test + fun `should be able to send and retrieve messages without room features`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + + val room = chatClient.rooms.get(roomId) + + room.attach() + + val messageEvent = CompletableDeferred() + + room.messages.subscribe { messageEvent.complete(it) } + room.messages.send("hello") + + assertEquals( + "hello", + messageEvent.await().message.text, + ) + } + + /** + * Spec: CHA-M3, CHA-M4 + */ + @Test + fun `should be able to send and retrieve messages with all room features enabled`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + + val room = chatClient.rooms.get(roomId, RoomOptions.default) + + room.attach() + + val messageEvent = CompletableDeferred() + room.messages.subscribe { messageEvent.complete(it) } + + val metadata = MessageMetadata() + metadata.addProperty("foo", "bar") + val headers = mapOf("headerKey" to "headerValue") + val sentMessage = room.messages.send("hello", metadata, headers) + + val receivedMessage = messageEvent.await().message + + assertEquals(roomId, receivedMessage.roomId) + assertEquals(MessageAction.MESSAGE_CREATE, receivedMessage.action) + assertEquals("hello", receivedMessage.text) + assertEquals("sandbox-client", receivedMessage.clientId) + assertTrue(receivedMessage.serial.isNotEmpty()) + assertEquals(receivedMessage.serial, receivedMessage.version) + assertEquals(receivedMessage.createdAt, receivedMessage.timestamp) + assertEquals(metadata.toString(), receivedMessage.metadata.toString()) + assertEquals(headers, receivedMessage.headers) + assertEquals(null, receivedMessage.operation) + + // check for sentMessage fields against receivedMessage fields + assertEquals(sentMessage.serial, receivedMessage.serial) + assertEquals(sentMessage.clientId, receivedMessage.clientId) + assertEquals(sentMessage.roomId, receivedMessage.roomId) + assertEquals(sentMessage.text, receivedMessage.text) + assertEquals(sentMessage.createdAt, receivedMessage.createdAt) + assertEquals(sentMessage.metadata.toString(), receivedMessage.metadata.toString()) + assertEquals(sentMessage.headers, receivedMessage.headers) + assertEquals(sentMessage.action, receivedMessage.action) + assertEquals(sentMessage.version, receivedMessage.version) + assertEquals(sentMessage.timestamp, receivedMessage.timestamp) + assertEquals(sentMessage.operation, receivedMessage.operation) + } + + /** + * Spec: CHA-M3, CHA-M6 + */ + @Test + fun `should be able to send and retrieve messages from history`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + + val room = chatClient.rooms.get(roomId) + + room.attach() + + val metadata = MessageMetadata() + metadata.addProperty("foo", "bar") + val headers = mapOf("headerKey" to "headerValue") + val sentMessage = room.messages.send("hello", metadata, headers) + + lateinit var messages: List + + assertWaiter { + messages = room.messages.get().items + messages.isNotEmpty() + } + assertEquals(1, messages.size) + val historyMessage = messages.first() + + assertEquals(roomId, historyMessage.roomId) + assertEquals(MessageAction.MESSAGE_CREATE, historyMessage.action) + assertEquals("hello", historyMessage.text) + assertEquals("sandbox-client", historyMessage.clientId) + assertTrue(historyMessage.serial.isNotEmpty()) + assertEquals(historyMessage.serial, historyMessage.version) + assertEquals(historyMessage.createdAt, historyMessage.timestamp) + assertEquals(metadata.toString(), historyMessage.metadata.toString()) + assertEquals(headers, historyMessage.headers) + assertEquals(null, historyMessage.operation) + + // check for sentMessage fields against historyMessage fields + assertEquals(sentMessage.serial, historyMessage.serial) + assertEquals(sentMessage.clientId, historyMessage.clientId) + assertEquals(sentMessage.roomId, historyMessage.roomId) + assertEquals(sentMessage.text, historyMessage.text) + assertEquals(sentMessage.createdAt, historyMessage.createdAt) + assertEquals(sentMessage.metadata.toString(), historyMessage.metadata.toString()) + assertEquals(sentMessage.headers, historyMessage.headers) + assertEquals(sentMessage.action, historyMessage.action) + assertEquals(sentMessage.version, historyMessage.version) + assertEquals(sentMessage.timestamp, historyMessage.timestamp) + assertEquals(sentMessage.operation, historyMessage.operation) + } + + /** + * Spec: CHA-M8, CHA-M4 + */ + @Test + fun `should be able to update a sent message`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + + val room = chatClient.rooms.get(roomId) + + room.attach() + assertWaiter { room.status == RoomStatus.Attached } + + val receivedMsges = mutableListOf() + room.messages.subscribe { receivedMsges.add(it.message) } + + val metadata = MessageMetadata() + metadata.addProperty("foo", "bar") + val sentMessage = room.messages.send("hello", metadata, mapOf("headerKey" to "headerValue")) + assertWaiter { receivedMsges.size == 1 } + + val updatedText = "hello updated" + val updatedMetadata = MessageMetadata() + updatedMetadata.addProperty("foo", "baz") + val headers = mapOf("headerKey" to "headerValue") + + val opDescription = "Updating message" + val opMetadata = mapOf("operation" to "update") + + val messageCopy = sentMessage.copy(text = updatedText, metadata = updatedMetadata, headers = headers) + val updatedMessage = room.messages.update( + messageCopy, + opDescription, + opMetadata, + ) + + assertEquals(MessageAction.MESSAGE_UPDATE, updatedMessage.action) + assertEquals(sentMessage.serial, updatedMessage.serial) + assertEquals(sentMessage.createdAt, updatedMessage.createdAt) + + assertWaiter { receivedMsges.size == 2 } + val receivedMsg2 = receivedMsges.last() + + assertEquals(updatedMessage.text, receivedMsg2.text) + assertEquals(updatedMessage.metadata.toString(), receivedMsg2.metadata.toString()) + assertEquals(updatedMessage.operation?.description, receivedMsg2.operation?.description) + assertEquals(updatedMessage.operation?.metadata, receivedMsg2.operation?.metadata) + assertEquals(updatedMessage.operation?.clientId, receivedMsg2.operation?.clientId) + assertEquals(updatedMessage.headers, receivedMsg2.headers) + assertEquals(updatedMessage.serial, receivedMsg2.serial) + assertEquals(updatedMessage.version, receivedMsg2.version) + assertEquals(updatedMessage.createdAt, receivedMsg2.createdAt) + assertEquals(updatedMessage.timestamp, receivedMsg2.timestamp) + assertEquals(updatedMessage.clientId, receivedMsg2.clientId) + assertEquals(updatedMessage.roomId, receivedMsg2.roomId) + assertEquals(updatedMessage.action, receivedMsg2.action) + } + + /** + * Spec: CHA-M9, CHA-M4 + */ + @Test + fun `should be able to delete a sent message`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + + val room = chatClient.rooms.get(roomId) + + room.attach() + assertWaiter { room.status == RoomStatus.Attached } + + val receivedMsges = mutableListOf() + room.messages.subscribe { receivedMsges.add(it.message) } + + val metadata = MessageMetadata() + metadata.addProperty("foo", "bar") + val sentMessage = room.messages.send("hello", metadata, mapOf("headerKey" to "headerValue")) + assertWaiter { receivedMsges.size == 1 } + + val description = "Deleting message" + val opMetadata = mapOf("operation" to "delete") + + val deletedMessage = room.messages.delete( + message = sentMessage, + operationDescription = description, + operationMetadata = opMetadata, + ) + + assertEquals(MessageAction.MESSAGE_DELETE, deletedMessage.action) + assertEquals(sentMessage.serial, deletedMessage.serial) + assertEquals(sentMessage.createdAt, deletedMessage.createdAt) + + assertWaiter { receivedMsges.size == 2 } + val receivedMsg2 = receivedMsges.last() + + assertEquals(deletedMessage.text, receivedMsg2.text) + assertEquals(deletedMessage.metadata.toString(), receivedMsg2.metadata.toString()) + assertEquals(deletedMessage.operation?.description, receivedMsg2.operation?.description) + assertEquals(deletedMessage.operation?.metadata, receivedMsg2.operation?.metadata) + assertEquals(deletedMessage.operation?.clientId, receivedMsg2.operation?.clientId) + assertEquals(deletedMessage.headers, receivedMsg2.headers) + assertEquals(deletedMessage.serial, receivedMsg2.serial) + assertEquals(deletedMessage.version, receivedMsg2.version) + assertEquals(deletedMessage.createdAt, receivedMsg2.createdAt) + assertEquals(deletedMessage.timestamp, receivedMsg2.timestamp) + assertEquals(deletedMessage.clientId, receivedMsg2.clientId) + assertEquals(deletedMessage.roomId, receivedMsg2.roomId) + assertEquals(deletedMessage.action, receivedMsg2.action) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/integration/OccupancyIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/OccupancyIntegrationTest.kt new file mode 100644 index 00000000..30bcf277 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/OccupancyIntegrationTest.kt @@ -0,0 +1,42 @@ +package com.ably.chat.integration + +import com.ably.chat.OccupancyEvent +import com.ably.chat.OccupancyOptions +import com.ably.chat.RoomOptions +import com.ably.chat.subscribeOnce +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test + +class OccupancyIntegrationTest { + + @Test + fun `should return occupancy for the client`() = runTest { + val chatClient = sandbox.createSandboxChatClient("client1") + val roomId = UUID.randomUUID().toString() + val roomOptions = RoomOptions(occupancy = OccupancyOptions()) + + val chatClientRoom = chatClient.rooms.get(roomId, roomOptions) + + val firstOccupancyEvent = CompletableDeferred() + chatClientRoom.occupancy.subscribeOnce { + firstOccupancyEvent.complete(it) + } + + chatClientRoom.attach() + assertEquals(OccupancyEvent(1, 0), firstOccupancyEvent.await()) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/integration/PresenceIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/PresenceIntegrationTest.kt new file mode 100644 index 00000000..84fab2b5 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/PresenceIntegrationTest.kt @@ -0,0 +1,41 @@ +package com.ably.chat.integration + +import com.ably.chat.RoomOptions +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test + +class PresenceIntegrationTest { + + @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(), RoomOptions.default) + room.attach() + val members = room.presence.get() + assertEquals(0, members.size) + } + + @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(), RoomOptions.default) + room.attach() + room.presence.enter() + val members = room.presence.get() + assertEquals(1, members.size) + assertEquals("sandbox-client", members.first().clientId) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/integration/ReactionsIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/ReactionsIntegrationTest.kt new file mode 100644 index 00000000..001d2a6b --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/ReactionsIntegrationTest.kt @@ -0,0 +1,45 @@ +package com.ably.chat.integration + +import com.ably.chat.Reaction +import com.ably.chat.RoomOptions +import com.ably.chat.RoomReactionsOptions +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test + +class ReactionsIntegrationTest { + + @Test + fun `should observe room reactions`() = runTest { + val chatClient = sandbox.createSandboxChatClient() + val roomId = UUID.randomUUID().toString() + val roomOptions = RoomOptions(reactions = RoomReactionsOptions()) + + val room = chatClient.rooms.get(roomId, roomOptions) + room.attach() + + val reactionEvent = CompletableDeferred() + + room.reactions.subscribe { reactionEvent.complete(it) } + + room.reactions.send("heart") + + assertEquals( + "heart", + reactionEvent.await().type, + ) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/RoomIntegrationTest.kt similarity index 90% rename from chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt rename to chat-android/src/test/java/com/ably/chat/integration/RoomIntegrationTest.kt index ba10b7d9..e23ab4e1 100644 --- a/chat-android/src/test/java/com/ably/chat/room/RoomIntegrationTest.kt +++ b/chat-android/src/test/java/com/ably/chat/integration/RoomIntegrationTest.kt @@ -1,26 +1,19 @@ -package com.ably.chat.room +package com.ably.chat.integration 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 com.ably.chat.room.LifecycleManager +import com.ably.chat.room.atomicCoroutineScope import java.util.UUID import kotlinx.coroutines.test.runTest import org.junit.Assert -import org.junit.Before +import org.junit.BeforeClass 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) @@ -100,4 +93,14 @@ class RoomIntegrationTest { chatClient.realtime.close() } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } } diff --git a/chat-android/src/test/java/com/ably/chat/Sandbox.kt b/chat-android/src/test/java/com/ably/chat/integration/Sandbox.kt similarity index 96% rename from chat-android/src/test/java/com/ably/chat/Sandbox.kt rename to chat-android/src/test/java/com/ably/chat/integration/Sandbox.kt index 2a65d68e..7c192af0 100644 --- a/chat-android/src/test/java/com/ably/chat/Sandbox.kt +++ b/chat-android/src/test/java/com/ably/chat/integration/Sandbox.kt @@ -1,5 +1,8 @@ -package com.ably.chat +package com.ably.chat.integration +import com.ably.chat.ClientOptions +import com.ably.chat.DefaultChatClient +import com.ably.chat.serverError import com.google.gson.JsonElement import com.google.gson.JsonParser import io.ably.lib.realtime.AblyRealtime diff --git a/chat-android/src/test/java/com/ably/chat/integration/TypingIntegrationTest.kt b/chat-android/src/test/java/com/ably/chat/integration/TypingIntegrationTest.kt new file mode 100644 index 00000000..05507482 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/integration/TypingIntegrationTest.kt @@ -0,0 +1,45 @@ +package com.ably.chat.integration + +import com.ably.chat.RoomOptions +import com.ably.chat.TypingEvent +import com.ably.chat.TypingOptions +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test + +class TypingIntegrationTest { + + @Test + fun `should return typing indication for client`() = runTest { + val chatClient1 = sandbox.createSandboxChatClient("client1") + val chatClient2 = sandbox.createSandboxChatClient("client2") + val roomId = UUID.randomUUID().toString() + val roomOptions = RoomOptions(typing = TypingOptions(timeoutMs = 10_000)) + val chatClient1Room = chatClient1.rooms.get(roomId, roomOptions) + chatClient1Room.attach() + val chatClient2Room = chatClient2.rooms.get(roomId, roomOptions) + chatClient2Room.attach() + + val deferredValue = CompletableDeferred() + chatClient2Room.typing.subscribe { + deferredValue.complete(it) + } + chatClient1Room.typing.start() + val typingEvent = deferredValue.await() + assertEquals(setOf("client1"), typingEvent.currentlyTyping) + assertEquals(setOf("client1"), chatClient2Room.typing.get()) + } + + companion object { + private lateinit var sandbox: Sandbox + + @JvmStatic + @BeforeClass + fun setUp() = runTest { + sandbox = Sandbox.createInstance() + } + } +} 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 e3b11b10..a734d1aa 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.ably.chat.ChatClient import com.ably.chat.Message +import com.ably.chat.MessageMetadata import com.ably.chat.RealtimeClient import com.ably.chat.Room import com.ably.chat.RoomOptions @@ -318,9 +319,11 @@ fun MessageBubblePreview() { roomId = "roomId", clientId = "clientId", createdAt = System.currentTimeMillis(), - metadata = null, + metadata = MessageMetadata(), headers = mapOf(), action = MessageAction.MESSAGE_CREATE, + version = "fake", + timestamp = System.currentTimeMillis(), ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a25fb4f4..911a2c9b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] -ably = "1.2.48" +ably = "1.2.50" junit = "4.13.2" agp = "8.5.2" detekt = "1.23.6"