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 dd0bf0ef..fdfc2f34 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -225,6 +225,7 @@ internal class DefaultMessages( private val roomId: String, realtimeChannels: AblyRealtime.Channels, private val chatApi: ChatApi, + logger: Logger, ) : Messages { private var listeners: Map> = emptyMap() 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 ca9c2986..87304c18 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -137,6 +137,7 @@ internal class DefaultPresence( private val clientId: String, override val channel: Channel, private val presence: PubSubPresence, + logger: Logger, ) : Presence { override suspend fun get(waitForSync: Boolean, clientId: String?, connectionId: String?): List { 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 1565887f..8a23e09d 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -109,52 +109,54 @@ 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( + override val messages = DefaultMessages( roomId = roomId, realtimeChannels = realtimeClient.channels, chatApi = chatApi, + logger = roomLogger.withContext(tag = "Messages"), ) - private val _typing: DefaultTyping = DefaultTyping( - roomId = roomId, - realtimeClient = realtimeClient, - options = options.typing, - clientId = clientId, - logger = logger.withContext(tag = "Typing"), - ) - - private val _occupancy = DefaultOccupancy( - roomId = roomId, - realtimeChannels = realtimeClient.channels, - chatApi = chatApi, - logger = logger.withContext(tag = "Occupancy"), - ) - - 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(logger) + private val statusLifecycle = DefaultRoomLifecycle(roomLogger) override val status: RoomStatus get() = statusLifecycle.status @@ -162,7 +164,53 @@ internal class DefaultRoom( override val error: ErrorInfo? get() = statusLifecycle.error - override fun onStatusChange(listener: RoomLifecycle.Listener): Subscription = statusLifecycle.onChange(listener) + init { + options.validateRoomOptions() // CHA-RC2a + + options.presence?.let { + val presenceContributor = DefaultPresence( + clientId = clientId, + channel = messages.channel, + presence = messages.channel.presence, + logger = roomLogger.withContext(tag = "Presence"), + ) + _presence = presenceContributor + } + + options.typing?.let { + val typingContributor = DefaultTyping( + roomId = roomId, + realtimeClient = realtimeClient, + clientId = clientId, + options = options.typing, + logger = roomLogger.withContext(tag = "Typing"), + ) + _typing = typingContributor + } + + options.reactions?.let { + val reactionsContributor = DefaultRoomReactions( + roomId = roomId, + clientId = clientId, + realtimeChannels = realtimeClient.channels, + logger = roomLogger.withContext(tag = "Reactions"), + ) + _reactions = reactionsContributor + } + + options.occupancy?.let { + val occupancyContributor = DefaultOccupancy( + roomId = roomId, + realtimeChannels = realtimeClient.channels, + chatApi = chatApi, + logger = roomLogger.withContext(tag = "Occupancy"), + ) + _occupancy = occupancyContributor + } + } + + override fun onStatusChange(listener: RoomLifecycle.Listener): Subscription = + statusLifecycle.onChange(listener) override fun offAllStatusChange() { statusLifecycle.offAll() @@ -170,19 +218,17 @@ internal class DefaultRoom( override suspend fun attach() { messages.channel.attachCoroutine() - typing.channel.attachCoroutine() - reactions.channel.attachCoroutine() + _typing?.channel?.attachCoroutine() + _reactions?.channel?.attachCoroutine() } override suspend fun detach() { messages.channel.detachCoroutine() - typing.channel.detachCoroutine() - reactions.channel.detachCoroutine() + _typing?.channel?.detachCoroutine() + _reactions?.channel?.detachCoroutine() } suspend fun release() { - _messages.release() - _typing.release() - _occupancy.release() + messages.release() } } 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 e0c05d55..f30940da 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomOptions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomOptions.kt @@ -70,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, ) /** @@ -84,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 f7cc3c5b..c1f87004 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -107,6 +107,7 @@ internal class DefaultRoomReactions( roomId: String, private val clientId: String, realtimeChannels: AblyRealtime.Channels, + logger: Logger, ) : RoomReactions { // (CHA-ER1) private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" 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 af6e2631..d9dd6c57 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 c43f421d..3a302edf 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 182c4e9a..2d6bf327 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/SandboxTest.kt b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt index 9f15b197..9b1a0b94 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/room/ConfigureRoomOptionsTest.kt b/chat-android/src/test/java/com/ably/chat/room/ConfigureRoomOptionsTest.kt new file mode 100644 index 00000000..b8e745e4 --- /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/detekt.yml b/detekt.yml index 58898210..f99f0553 100644 --- a/detekt.yml +++ b/detekt.yml @@ -990,7 +990,7 @@ style: - 'Preview' UnusedPrivateProperty: active: true - allowedNames: '_|ignored|expected|serialVersionUID' + allowedNames: '_|ignored|expected|serialVersionUID|logger' UseAnyOrNoneInsteadOfFind: active: false UseArrayLiteralsInAnnotations: