From dbbfe45f3935c71e6d630263446c58bd960422c5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 5 Nov 2024 12:40:12 +0000 Subject: [PATCH 1/3] [ECO-5082] feat: presence basic implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implementation doesn’t fully follow the spec; it omits all points that reference the room lifecycle state --- chat-android/build.gradle.kts | 1 + .../src/main/java/com/ably/chat/Presence.kt | 89 ++++++++--- .../src/main/java/com/ably/chat/Room.kt | 10 +- .../src/main/java/com/ably/chat/Utils.kt | 63 ++++++++ .../test/java/com/ably/chat/PresenceTest.kt | 144 ++++++++++++++++++ 5 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 chat-android/src/test/java/com/ably/chat/PresenceTest.kt diff --git a/chat-android/build.gradle.kts b/chat-android/build.gradle.kts index 26a3767..c9231f1 100644 --- a/chat-android/build.gradle.kts +++ b/chat-android/build.gradle.kts @@ -45,6 +45,7 @@ buildConfig { dependencies { api(libs.ably.android) implementation(libs.gson) + implementation(libs.coroutine.core) testImplementation(libs.junit) testImplementation(libs.mockk) 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 2973e48..af750ab 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -2,11 +2,18 @@ package com.ably.chat -import android.text.PrecomputedText.Params +import com.google.gson.JsonElement +import com.google.gson.JsonObject import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.Presence.GET_CLIENTID +import io.ably.lib.realtime.Presence.GET_CONNECTIONID +import io.ably.lib.realtime.Presence.GET_WAITFORSYNC +import io.ably.lib.types.Param import io.ably.lib.types.PresenceMessage +import io.ably.lib.realtime.Presence as PubSubPresence +import io.ably.lib.realtime.Presence.PresenceListener as PubSubPresenceListener -typealias PresenceData = Any +typealias PresenceData = JsonElement /** * This interface is used to interact with presence in a chat room: subscribing to presence events, @@ -22,11 +29,11 @@ interface Presence : EmitsDiscontinuities { val channel: Channel /** - * Method to get list of the current online users and returns the latest presence messages associated to it. - * @param {Ably.RealtimePresenceParams} params - Parameters that control how the presence set is retrieved. - * @returns {Promise} or upon failure, the promise will be rejected with an [[Ably.ErrorInfo]] object which explains the error. + * Method to get list of the current online users and returns the latest presence messages associated to it. + * @param {Ably.RealtimePresenceParams} params - Parameters that control how the presence set is retrieved. + * @returns {List} or upon failure, the promise will throw [[Ably.ErrorInfo]] object which explains the error. */ - suspend fun get(params: List): List + suspend fun get(waitForSync: Boolean = true, clientId: String? = null, connectionId: String? = null): List /** * Method to check if user with supplied clientId is online @@ -40,21 +47,21 @@ interface Presence : EmitsDiscontinuities { * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - suspend fun enter(data: PresenceData?) + suspend fun enter(data: PresenceData? = null) /** * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - suspend fun update(data: PresenceData?) + suspend fun update(data: PresenceData? = null) /** * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ - suspend fun leave(data: PresenceData?) + suspend fun leave(data: PresenceData? = null) /** * Subscribe the given listener to all presence events. @@ -86,7 +93,7 @@ data class PresenceMember( /** * The data associated with the presence member. */ - val data: PresenceData, + val data: PresenceData?, /** * The current state of the presence member. @@ -121,46 +128,80 @@ data class PresenceEvent( /** * The timestamp of the presence event. */ - val timestamp: Int, + val timestamp: Long, /** * The data associated with the presence event. */ - val data: PresenceData, + val data: PresenceData?, ) internal class DefaultPresence( - private val messages: Messages, + private val clientId: String, + override val channel: Channel, + private val presence: PubSubPresence, ) : Presence { - override val channel: Channel - get() = messages.channel - - override suspend fun get(params: List): List { - TODO("Not yet implemented") + suspend fun get(params: List): List { + val usersOnPresence = presence.getCoroutine(params) + return usersOnPresence.map { user -> + PresenceMember( + clientId = user.clientId, + action = user.action, + data = (user.data as? JsonObject)?.get("userCustomData"), + updatedAt = user.timestamp, + ) + } } - override suspend fun isUserPresent(clientId: String): Boolean { - TODO("Not yet implemented") + override suspend fun get(waitForSync: Boolean, clientId: String?, connectionId: String?): List { + val params = buildList { + if (waitForSync) add(Param(GET_WAITFORSYNC, true)) + clientId?.let { add(Param(GET_CLIENTID, it)) } + connectionId?.let { add(Param(GET_CONNECTIONID, it)) } + } + return get(params) } + override suspend fun isUserPresent(clientId: String): Boolean = presence.getCoroutine(Param(GET_CLIENTID, clientId)).isNotEmpty() + override suspend fun enter(data: PresenceData?) { - TODO("Not yet implemented") + presence.enterClientCoroutine(clientId, wrapInUserCustomData(data)) } override suspend fun update(data: PresenceData?) { - TODO("Not yet implemented") + presence.updateClientCoroutine(clientId, wrapInUserCustomData(data)) } override suspend fun leave(data: PresenceData?) { - TODO("Not yet implemented") + presence.leaveClientCoroutine(clientId, wrapInUserCustomData(data)) } override fun subscribe(listener: Presence.Listener): Subscription { - TODO("Not yet implemented") + val presenceListener = PubSubPresenceListener { + val presenceEvent = PresenceEvent( + action = it.action, + clientId = it.clientId, + timestamp = it.timestamp, + data = (it.data as? JsonObject)?.get("userCustomData"), + ) + listener.onEvent(presenceEvent) + } + + presence.subscribe(presenceListener) + + return Subscription { + presence.unsubscribe(presenceListener) + } } override fun onDiscontinuity(listener: EmitsDiscontinuities.Listener): Subscription { TODO("Not yet implemented") } + + private fun wrapInUserCustomData(data: PresenceData?) = data?.let { + JsonObject().apply { + add("userCustomData", data) + } + } } 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 0ff81d9..e95097e 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -87,10 +87,12 @@ interface Room { internal class DefaultRoom( override val roomId: String, override val options: RoomOptions, - realtimeClient: RealtimeClient, + val realtimeClient: RealtimeClient, chatApi: ChatApi, ) : Room { + private val clientId get() = realtimeClient.auth.clientId + private val _messages = DefaultMessages( roomId = roomId, realtimeChannels = realtimeClient.channels, @@ -100,12 +102,14 @@ internal class DefaultRoom( override val messages: Messages = _messages override val presence: Presence = DefaultPresence( - messages = messages, + channel = messages.channel, + clientId = clientId, + presence = messages.channel.presence, ) override val reactions: RoomReactions = DefaultRoomReactions( roomId = roomId, - clientId = realtimeClient.auth.clientId, + clientId = clientId, realtimeChannels = realtimeClient.channels, ) 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 8f57352..5d6f3bc 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -1,13 +1,19 @@ package com.ably.chat +import com.google.gson.JsonElement import io.ably.lib.realtime.Channel import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.AblyException import io.ably.lib.types.ChannelOptions import io.ably.lib.types.ErrorInfo +import io.ably.lib.types.Param import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import io.ably.lib.realtime.Presence as PubSubPresence const val AGENT_PARAMETER_NAME = "agent" @@ -50,6 +56,63 @@ suspend fun Channel.publishCoroutine(message: PubSubMessage) = suspendCoroutine ) } +suspend fun PubSubPresence.getCoroutine(param: Param) = withContext(Dispatchers.IO) { + get(param) +} + +@Suppress("SpreadOperator") +suspend fun PubSubPresence.getCoroutine(params: List) = withContext(Dispatchers.IO) { + get(*params.toTypedArray()) +} + +suspend fun PubSubPresence.enterClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + enterClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + +suspend fun PubSubPresence.updateClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + updateClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + +suspend fun PubSubPresence.leaveClientCoroutine(clientId: String, data: JsonElement?) = suspendCancellableCoroutine { continuation -> + leaveClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } + + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) +} + @Suppress("FunctionName") fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { val options = ChannelOptions() diff --git a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt new file mode 100644 index 0000000..c43f421 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt @@ -0,0 +1,144 @@ +package com.ably.chat + +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.Presence.PresenceListener +import io.ably.lib.realtime.buildRealtimeChannel +import io.ably.lib.types.PresenceMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import io.ably.lib.realtime.Presence as PubSubPresence + +class PresenceTest { + private val pubSubChannel = spyk(buildRealtimeChannel("room1::\$chat::\$messages")) + private val pubSubPresence = mockk(relaxed = true) + private lateinit var presence: DefaultPresence + + @Before + fun setUp() { + presence = DefaultPresence( + clientId = "client1", + channel = pubSubChannel, + presence = pubSubPresence, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if there is no data`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = null, + ), + presenceEvent, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if there is no 'userCustomData'`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + data = JsonObject() + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = null, + ), + presenceEvent, + ) + } + + /** + * @spec CHA-PR2a + */ + @Test + fun `should transform PresenceMessage into Chat's PresenceEvent if 'userCustomData' is primitive`() = runTest { + val presenceListenerSlot = slot() + + every { pubSubPresence.subscribe(capture(presenceListenerSlot)) } returns Unit + + val deferredValue = DeferredValue() + + presence.subscribe { + deferredValue.completeWith(it) + } + + presenceListenerSlot.captured.onPresenceMessage( + PresenceMessage().apply { + action = PresenceMessage.Action.leave + clientId = "client1" + timestamp = 100_000L + data = JsonObject().apply { + addProperty("userCustomData", "user") + } + }, + ) + + val presenceEvent = deferredValue.await() + + assertEquals( + PresenceEvent( + action = PresenceMessage.Action.leave, + clientId = "client1", + timestamp = 100_000L, + data = JsonPrimitive("user"), + ), + presenceEvent, + ) + } +} From d6b38bab35a080c0a694cca99a30afa6267aaf24 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 5 Nov 2024 14:12:23 +0000 Subject: [PATCH 2/3] [ECO-5082] feat: add presence popup for example app --- .../com/ably/chat/example/MainActivity.kt | 49 ++++++-- .../java/com/ably/chat/example/Settings.kt | 5 + .../com/ably/chat/example/ui/PresencePopup.kt | 109 ++++++++++++++++++ 3 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 example/src/main/java/com/ably/chat/example/Settings.kt create mode 100644 example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt diff --git a/example/src/main/java/com/ably/chat/example/MainActivity.kt b/example/src/main/java/com/ably/chat/example/MainActivity.kt index 4a851c0..19afd48 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -16,10 +16,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -38,6 +44,7 @@ import com.ably.chat.Message import com.ably.chat.RealtimeClient import com.ably.chat.SendMessageParams import com.ably.chat.SendReactionParams +import com.ably.chat.example.ui.PresencePopup import com.ably.chat.example.ui.theme.AblyChatExampleTheme import io.ably.lib.types.ClientOptions import java.util.UUID @@ -62,17 +69,40 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { AblyChatExampleTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Chat( - chatClient, - modifier = Modifier.padding(innerPadding), - ) - } + App(chatClient) } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun App(chatClient: ChatClient) { + var showPopup by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Chat") }, + actions = { + IconButton(onClick = { showPopup = true }) { + Icon(Icons.Default.Person, contentDescription = "Show members") + } + }, + ) + }, + ) { innerPadding -> + Chat( + chatClient, + modifier = Modifier.padding(innerPadding), + ) + if (showPopup) { + PresencePopup(chatClient, onDismiss = { showPopup = false }) + } + } +} + @SuppressWarnings("LongMethod") @Composable fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { @@ -83,8 +113,7 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() var receivedReactions by remember { mutableStateOf>(listOf()) } - val roomId = "my-room" - val room = chatClient.rooms.get(roomId) + val room = chatClient.rooms.get(Settings.ROOM_ID) DisposableEffect(Unit) { coroutineScope.launch { @@ -130,7 +159,9 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { verticalArrangement = Arrangement.SpaceBetween, ) { LazyColumn( - modifier = Modifier.weight(1f).padding(16.dp), + modifier = Modifier + .weight(1f) + .padding(16.dp), userScrollEnabled = true, state = listState, ) { diff --git a/example/src/main/java/com/ably/chat/example/Settings.kt b/example/src/main/java/com/ably/chat/example/Settings.kt new file mode 100644 index 0000000..e169402 --- /dev/null +++ b/example/src/main/java/com/ably/chat/example/Settings.kt @@ -0,0 +1,5 @@ +package com.ably.chat.example + +object Settings { + const val ROOM_ID = "my-room" +} diff --git a/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt new file mode 100644 index 0000000..cfc52c2 --- /dev/null +++ b/example/src/main/java/com/ably/chat/example/ui/PresencePopup.kt @@ -0,0 +1,109 @@ +package com.ably.chat.example.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import com.ably.chat.ChatClient +import com.ably.chat.PresenceMember +import com.ably.chat.Subscription +import com.ably.chat.example.Settings +import com.google.gson.JsonObject +import kotlinx.coroutines.launch + +@SuppressWarnings("LongMethod") +@Composable +fun PresencePopup(chatClient: ChatClient, onDismiss: () -> Unit) { + var members by remember { mutableStateOf(listOf()) } + val coroutineScope = rememberCoroutineScope() + val presence = chatClient.rooms.get(Settings.ROOM_ID).presence + + DisposableEffect(Unit) { + var subscription: Subscription? = null + + coroutineScope.launch { + members = presence.get() + subscription = presence.subscribe { + coroutineScope.launch { + members = presence.get() + } + } + } + + onDispose { + subscription?.unsubscribe() + } + } + + Popup( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.padding(16.dp), + shape = MaterialTheme.shapes.medium, + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth(), + ) { + Text("Chat Members", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(8.dp)) + members.forEach { member -> + BasicText("${member.clientId} - (${(member.data as? JsonObject)?.get("status")?.asString})") + Spacer(modifier = Modifier.height(4.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + coroutineScope.launch { + presence.enter( + JsonObject().apply { + addProperty("status", "online") + }, + ) + } + }) { + Text("Join") + } + Button(onClick = { + coroutineScope.launch { + presence.enter( + JsonObject().apply { + addProperty("status", "away") + }, + ) + } + }) { + Text("Appear away") + } + Button(onClick = { + coroutineScope.launch { + presence.leave() + } + }) { + Text("Leave") + } + Button(onClick = onDismiss) { + Text("Close") + } + } + } + } +} From 492fba81eb3e4b49f39866f7be707772081520cb Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 13 Nov 2024 11:46:28 +0000 Subject: [PATCH 3/3] [ECO-5082] chore: add sandbox for presence --- .../src/main/java/com/ably/chat/Presence.kt | 9 +++++---- .../src/test/java/com/ably/chat/SandboxTest.kt | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) 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 af750ab..c0ffa9d 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -31,7 +31,8 @@ interface Presence : EmitsDiscontinuities { /** * Method to get list of the current online users and returns the latest presence messages associated to it. * @param {Ably.RealtimePresenceParams} params - Parameters that control how the presence set is retrieved. - * @returns {List} or upon failure, the promise will throw [[Ably.ErrorInfo]] object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. + * @returns {List} */ suspend fun get(waitForSync: Boolean = true, clientId: String? = null, connectionId: String? = null): List @@ -45,21 +46,21 @@ interface Presence : EmitsDiscontinuities { /** * Method to join room presence, will emit an enter event to all subscribers. Repeat calls will trigger more enter events. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ suspend fun enter(data: PresenceData? = null) /** * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ suspend fun update(data: PresenceData? = null) /** * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. * @param {PresenceData} data - The users data, a JSON serializable object that will be sent to all subscribers. - * @returns {Promise} or upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. + * @throws {@link io.ably.lib.types.AblyException} object which explains the error. */ suspend fun leave(data: PresenceData? = null) 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 0411e58..9c459af 100644 --- a/chat-android/src/test/java/com/ably/chat/SandboxTest.kt +++ b/chat-android/src/test/java/com/ably/chat/SandboxTest.kt @@ -1,6 +1,5 @@ package com.ably.chat -import io.ably.lib.realtime.ChannelState import java.util.UUID import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -17,10 +16,22 @@ class SandboxTest { } @Test - fun basicIntegrationTest() = runTest { + fun `should return empty list of presence members if nobody is entered`() = runTest { val chatClient = sandbox.createSandboxChatClient() val room = chatClient.rooms.get(UUID.randomUUID().toString()) room.attach() - assertEquals(ChannelState.attached, room.messages.channel.state) + 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() + val room = chatClient.rooms.get(UUID.randomUUID().toString()) + room.attach() + room.presence.enter() + val members = room.presence.get() + assertEquals(1, members.size) + assertEquals("sandbox-client", members.first().clientId) } }