From 76e3d5d7d899826a449d1c86a1c2d98a91618fad Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 19 Nov 2024 00:06:21 +0000 Subject: [PATCH] feat: add sandbox test for typing and updated example app --- .../src/main/java/com/ably/chat/Typing.kt | 1 - .../src/main/java/com/ably/chat/Utils.kt | 61 +++++++------ .../test/java/com/ably/chat/SandboxTest.kt | 22 +++++ .../com/ably/chat/example/MainActivity.kt | 91 +++++++++++++++---- 4 files changed, 125 insertions(+), 50 deletions(-) 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 6160787e..f5e2a177 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -10,7 +10,6 @@ import kotlin.math.min import kotlin.math.pow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel 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 9bf65455..2584d2e2 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -1,6 +1,7 @@ package com.ably.chat import com.google.gson.JsonElement +import com.google.gson.JsonNull import io.ably.lib.realtime.Channel import io.ably.lib.realtime.CompletionListener import io.ably.lib.realtime.Presence.GET_CLIENTID @@ -75,23 +76,24 @@ suspend fun PubSubPresence.getCoroutine( get(*params.toTypedArray()).asList() } -suspend fun PubSubPresence.enterClientCoroutine(clientId: String, data: JsonElement? = null) = suspendCancellableCoroutine { continuation -> - enterClient( - clientId, - data, - object : CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } +suspend fun PubSubPresence.enterClientCoroutine(clientId: String, data: JsonElement? = JsonNull.INSTANCE) = + suspendCancellableCoroutine { continuation -> + enterClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } - override fun onError(reason: ErrorInfo?) { - continuation.resumeWithException(AblyException.fromErrorInfo(reason)) - } - }, - ) -} + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) + } -suspend fun PubSubPresence.updateClientCoroutine(clientId: String, data: JsonElement? = null) = +suspend fun PubSubPresence.updateClientCoroutine(clientId: String, data: JsonElement? = JsonNull.INSTANCE) = suspendCancellableCoroutine { continuation -> updateClient( clientId, @@ -108,21 +110,22 @@ suspend fun PubSubPresence.updateClientCoroutine(clientId: String, data: JsonEle ) } -suspend fun PubSubPresence.leaveClientCoroutine(clientId: String, data: JsonElement? = null) = suspendCancellableCoroutine { continuation -> - leaveClient( - clientId, - data, - object : CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } +suspend fun PubSubPresence.leaveClientCoroutine(clientId: String, data: JsonElement? = JsonNull.INSTANCE) = + suspendCancellableCoroutine { continuation -> + leaveClient( + clientId, + data, + object : CompletionListener { + override fun onSuccess() { + continuation.resume(Unit) + } - override fun onError(reason: ErrorInfo?) { - continuation.resumeWithException(AblyException.fromErrorInfo(reason)) - } - }, - ) -} + override fun onError(reason: ErrorInfo?) { + continuation.resumeWithException(AblyException.fromErrorInfo(reason)) + } + }, + ) + } @Suppress("FunctionName") fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { 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 6da469b7..2140198f 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,7 @@ 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.Before @@ -34,4 +35,25 @@ class SandboxTest { 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()) + } } 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 19afd48a..2af93730 100644 --- a/example/src/main/java/com/ably/chat/example/MainActivity.kt +++ b/example/src/main/java/com/ably/chat/example/MainActivity.kt @@ -22,12 +22,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme 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.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,13 +43,20 @@ 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.PresenceOptions import com.ably.chat.RealtimeClient +import com.ably.chat.Room +import com.ably.chat.RoomOptions +import com.ably.chat.RoomReactionsOptions import com.ably.chat.SendMessageParams import com.ably.chat.SendReactionParams +import com.ably.chat.Typing +import com.ably.chat.TypingOptions import com.ably.chat.example.ui.PresencePopup import com.ably.chat.example.ui.theme.AblyChatExampleTheme import io.ably.lib.types.ClientOptions import java.util.UUID +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch val randomClientId = UUID.randomUUID().toString() @@ -79,12 +88,29 @@ class MainActivity : ComponentActivity() { @Composable fun App(chatClient: ChatClient) { var showPopup by remember { mutableStateOf(false) } + val room = chatClient.rooms.get( + Settings.ROOM_ID, + RoomOptions(typing = TypingOptions(), presence = PresenceOptions(), reactions = RoomReactionsOptions), + ) + val coroutineScope = rememberCoroutineScope() + val currentlyTyping by typingUsers(room.typing) + + DisposableEffect(Unit) { + coroutineScope.launch { + room.attach() + } + onDispose { + coroutineScope.launch { + room.detach() + } + } + } Scaffold( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Chat") }, + title = { Text(Settings.ROOM_ID) }, actions = { IconButton(onClick = { showPopup = true }) { Icon(Icons.Default.Person, contentDescription = "Show members") @@ -93,10 +119,26 @@ fun App(chatClient: ChatClient) { ) }, ) { innerPadding -> - Chat( - chatClient, - modifier = Modifier.padding(innerPadding), - ) + Column( + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + if (currentlyTyping.isNotEmpty()) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = "Currently typing: ${currentlyTyping.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall.copy( + color = Color.Gray, + ), + ) + } + Chat( + room, + modifier = Modifier.padding(16.dp), + ) + } + if (showPopup) { PresencePopup(chatClient, onDismiss = { showPopup = false }) } @@ -105,7 +147,7 @@ fun App(chatClient: ChatClient) { @SuppressWarnings("LongMethod") @Composable -fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { +fun Chat(room: Room, modifier: Modifier = Modifier) { var messageText by remember { mutableStateOf(TextFieldValue("")) } var sending by remember { mutableStateOf(false) } var messages by remember { mutableStateOf(listOf()) } @@ -113,19 +155,6 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() var receivedReactions by remember { mutableStateOf>(listOf()) } - val room = chatClient.rooms.get(Settings.ROOM_ID) - - DisposableEffect(Unit) { - coroutineScope.launch { - room.attach() - } - onDispose { - coroutineScope.launch { - room.detach() - } - } - } - DisposableEffect(Unit) { val subscription = room.messages.subscribe { messages += it.message @@ -173,7 +202,12 @@ fun Chat(chatClient: ChatClient, modifier: Modifier = Modifier) { ChatInputField( sending = sending, messageInput = messageText, - onMessageChange = { messageText = it }, + onMessageChange = { + messageText = it + coroutineScope.launch { + room.typing.start() + } + }, onSendClick = { sending = true coroutineScope.launch { @@ -258,6 +292,23 @@ fun ChatInputField( } } +@Composable +fun typingUsers(typing: Typing): State> { + val currentlyTyping = remember { mutableStateOf(emptySet()) } + + DisposableEffect(typing) { + val subscription = typing.subscribe { typingEvent -> + currentlyTyping.value = typingEvent.currentlyTyping - randomClientId + } + + onDispose { + subscription.unsubscribe() + } + } + + return currentlyTyping +} + @Preview @Composable fun MessageBubblePreview() {