From b907d1991d5e3ec0150fbf45776c23565a494129 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 22 Oct 2026 05:04:45 +0000 Subject: [PATCH 1/3] fix(chat): show typing only from participants of the current conversation The signaling connection of an account is shared by all conversations, but the session map of WebSocketInstance was filled by join events and cleared only by leave events. It kept the sessions of earlier rooms, so typing messages went to sessions of other rooms and typing from such sessions was shown as "Guest". - RoomSessionRegistry replaces the session map. It holds only the sessions of the current room and is cleared when the server answers with another room, when the room is left, after a hello without resume and on no_such_session / no_such_room. A resumed connection keeps it. - Join events of a left room are dropped: after a leave the room is unknown, so the answer to the leave clears the sessions as well. - Typing messages are sent only to the sessions of the current room and accepted only from them. - The leave event reaches the chat listener (onSessionsLeft), so the typing indicator of a session which left the room is hidden at once. - Typing is stopped and expired per session, not per participant. The tests of WebSocketInstance use mockwebserver3 and hand the client and the event bus to the production code through a mocked application component, so the production classes need no test seam. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../com/nextcloud/talk/chat/ChatActivity.kt | 60 ++- .../talk/chat/MessageInputFragment.kt | 26 +- .../nextcloud/talk/chat/TypingParticipant.kt | 64 ++- .../signaling/ConversationMessageNotifier.kt | 7 + .../signaling/SignalingMessageReceiver.kt | 14 + .../talk/webrtc/RoomSessionRegistry.kt | 96 +++++ .../talk/webrtc/WebSocketInstance.kt | 58 +-- .../talk/chat/TypingParticipantTest.kt | 158 +++++++ ...ignalingMessageReceiverSessionsLeftTest.kt | 47 +++ .../talk/webrtc/RoomSessionRegistryTest.kt | 236 +++++++++++ .../WebSocketInstanceRoomSessionsTest.kt | 389 ++++++++++++++++++ 11 files changed, 1066 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/TypingParticipantTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverSessionsLeftTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 18664a8bcd7..bb082a3bbbb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -499,38 +499,58 @@ class ChatActivity : private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener { override fun onStartTyping(userId: String?, session: String?) { + if (session == null) { + return + } val userIdOrGuestSession = userId ?: session - if (isTypingStatusEnabled() && conversationUser.userId != userIdOrGuestSession) { - var displayName = webSocketInstance?.getDisplayNameForSession(session) + if (isTypingStatusEnabled() && + conversationUser.userId != userIdOrGuestSession && + webSocketInstance?.isSessionInCurrentRoom(session) == true + ) { + var displayName = webSocketInstance?.getDisplayNameForSession(session).orEmpty() + if (displayName == "") { + displayName = context.resources?.getString(R.string.nc_guest)!! + } - if (displayName != null && !typingParticipants.contains(userIdOrGuestSession)) { - if (displayName == "") { - displayName = context.resources?.getString(R.string.nc_guest)!! + runOnUiThread { + val typingParticipant = typingParticipants[userIdOrGuestSession] + if (typingParticipant != null) { + typingParticipant.addSession(session) + } else { + typingParticipants[userIdOrGuestSession] = + TypingParticipant(userIdOrGuestSession, displayName, session) { participant -> + typingParticipants.remove(userIdOrGuestSession, participant) + updateTypingIndicator() + } + updateTypingIndicator() } + } + } + } - runOnUiThread { - val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) { - typingParticipants.remove(userIdOrGuestSession) - updateTypingIndicator() - } + override fun onStopTyping(userId: String?, session: String?) { + if (session == null) { + return + } - typingParticipants[userIdOrGuestSession] = typingParticipant + if (isTypingStatusEnabled() && + conversationUser.userId != userId && + webSocketInstance?.isSessionInCurrentRoom(session) == true + ) { + runOnUiThread { + if (TypingParticipant.stopTyping(typingParticipants, userId ?: session, session)) { updateTypingIndicator() } - } else if (typingParticipants.contains(userIdOrGuestSession)) { - typingParticipants[userIdOrGuestSession]?.restartTimer() } } } - override fun onStopTyping(userId: String?, session: String?) { - val userIdOrGuestSession = userId ?: session - - if (isTypingStatusEnabled() && conversationUser.userId != userId) { - typingParticipants[userIdOrGuestSession]?.cancelTimer() - typingParticipants.remove(userIdOrGuestSession) - updateTypingIndicator() + override fun onSessionsLeft(sessions: List) { + runOnUiThread { + if (TypingParticipant.removeSessions(typingParticipants, sessions)) { + updateTypingIndicator() + } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 26ad06700fa..a3cef76de90 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -978,14 +978,11 @@ class MessageInputFragment : Fragment() { fun updateOwnTypingStatus(typedText: CharSequence) { fun sendStartTypingSignalingMessage() { - val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() - if (concurrentSafeHashMap != null) { - for ((sessionId, _) in concurrentSafeHashMap) { - val ncSignalingMessage = NCSignalingMessageDto() - ncSignalingMessage.to = sessionId - ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE - chatActivity.signalingMessageSender!!.send(ncSignalingMessage) - } + chatActivity.webSocketInstance?.getRoomSessionIds()?.forEach { sessionId -> + val ncSignalingMessage = NCSignalingMessageDto() + ncSignalingMessage.to = sessionId + ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE + chatActivity.signalingMessageSender!!.send(ncSignalingMessage) } } @@ -1025,14 +1022,11 @@ class MessageInputFragment : Fragment() { typingTimer = null typedWhileTypingTimerIsRunning = false - val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() - if (concurrentSafeHashMap != null) { - for ((sessionId, _) in concurrentSafeHashMap) { - val ncSignalingMessage = NCSignalingMessageDto() - ncSignalingMessage.to = sessionId - ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE - chatActivity.signalingMessageSender?.send(ncSignalingMessage) - } + chatActivity.webSocketInstance?.getRoomSessionIds()?.forEach { sessionId -> + val ncSignalingMessage = NCSignalingMessageDto() + ncSignalingMessage.to = sessionId + ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE + chatActivity.signalingMessageSender?.send(ncSignalingMessage) } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt index 85d7e543987..0ffa7b4577c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt @@ -8,15 +8,26 @@ package com.nextcloud.talk.chat import android.os.CountDownTimer -class TypingParticipant(val userId: String, val name: String, val funToCallWhenTimeIsUp: (userId: String) -> Unit) { - var timer: CountDownTimer? = null +class TypingParticipant( + val userId: String, + val name: String, + session: String, + val funToCallWhenTimeIsUp: (participant: TypingParticipant) -> Unit +) { + // one timer per signaling session of the user (e.g. a device), the keys are the sessions which type + private val timers = mutableMapOf() init { - startTimer() + addSession(session) } - private fun startTimer() { - timer = object : CountDownTimer( + /** + * A session of the user types in the room. Starts the 15 s timer of the session or restarts it when the session + * types already. Each session expires on its own, the participant expires with its last session. + */ + fun addSession(session: String) { + timers.remove(session)?.cancel() + timers[session] = object : CountDownTimer( TYPING_DURATION_TO_HIDE_TYPING_MESSAGE, TYPING_DURATION_TO_HIDE_TYPING_MESSAGE ) { @@ -25,21 +36,48 @@ class TypingParticipant(val userId: String, val name: String, val funToCallWhenT } override fun onFinish() { - funToCallWhenTimeIsUp(userId) + if (timers.remove(session, this) && timers.isEmpty()) { + funToCallWhenTimeIsUp(this@TypingParticipant) + } } }.start() } - fun restartTimer() { - timer?.cancel() - timer?.start() - } - - fun cancelTimer() { - timer?.cancel() + /** Forgets the [left] sessions and cancels their timers. Returns whether the participant had any of them. */ + private fun forgetSessions(left: Collection): Boolean { + val known = left.filter { timers.containsKey(it) } + known.forEach { timers.remove(it)?.cancel() } + return known.isNotEmpty() } companion object { + /** + * A [session] of the participant [key] stopped typing: only this session is forgotten, the participant is + * removed with its last session. Returns whether the participant was removed. + */ + fun stopTyping(participants: MutableMap, key: String, session: String): Boolean { + val participant = participants[key] + val removed = participant != null && + participant.forgetSessions(listOf(session)) && + participant.timers.isEmpty() + if (removed) { + participants.remove(key) + } + return removed + } + + /** + * Forgets the [sessions] which left the room and removes the typing participants which have no session + * left (a user may type with several sessions). Returns whether any participant was removed. + */ + fun removeSessions(participants: MutableMap, sessions: Collection): Boolean { + val left = participants.filterValues { participant -> + participant.forgetSessions(sessions) && participant.timers.isEmpty() + } + left.keys.forEach { participants.remove(it) } + return left.isNotEmpty() + } + private const val TYPING_DURATION_TO_HIDE_TYPING_MESSAGE = 15000L } } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt index 7d2b61e25f3..542fef7b7db 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt @@ -42,4 +42,11 @@ internal class ConversationMessageNotifier { listener.onStopTyping(userId, sessionId) } } + + @Synchronized + fun notifySessionsLeft(sessions: List) { + for (listener in ArrayList(conversationMessageListeners)) { + listener.onSessionsLeft(sessions) + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt index 26b64ff6c0c..5e19dce45e6 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt @@ -172,6 +172,13 @@ abstract class SignalingMessageReceiver { interface ConversationMessageListener { fun onStartTyping(userId: String?, session: String?) fun onStopTyping(userId: String?, session: String?) + + /** + * Sessions which left the room. Their typing indicator is not going to be stopped by them any more. + */ + fun onSessionsLeft(sessions: List) { + // optional + } fun onChatMessagesReceived(chatMessages: List) } @@ -326,6 +333,13 @@ abstract class SignalingMessageReceiver { return } + + if ("room" == eventMap?.get("target") && "leave" == eventMap["type"]) { + val leftSessions = (eventMap["leave"] as? List<*>)?.filterIsInstance() + if (!leftSessions.isNullOrEmpty()) { + conversationMessageNotifier.notifySessionsLeft(leftSessions) + } + } } private fun processSwitchToEvent(eventMap: Map?) { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt new file mode 100644 index 00000000000..f592cb4bce1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import com.nextcloud.talk.models.json.participants.ParticipantDto +import com.nextcloud.talk.models.json.participants.ParticipantDto.ActorType +import java.util.concurrent.ConcurrentHashMap + +/** + * The signaling sessions of the room the external signaling connection is currently in. + * + * One [WebSocketInstance] lives per account and is shared by all conversations opened with that account, but the + * signaling server only reports the sessions of the room the connection joined. So the set must hold exactly the + * sessions of the current room: sessions of a previous room (or of an expired connection) must not stay in it, + * otherwise typing messages are sent to sessions of other rooms and typing messages of such sessions are accepted + * (the server delivers messages addressed to a session no matter which room that session is in). + * + * Written from the OkHttp thread and from the UI thread ([reset] on leaving a room), read from the UI thread. + * The changes of the room and of the sessions ([onRoomResponse], [reset], [onJoin]) share one lock, so a join cannot + * store a session after a [reset] cleared the registry. + */ +class RoomSessionRegistry { + private val sessions = ConcurrentHashMap() + + @Volatile + private var roomToken: String? = null + + /** + * The server answers every room join/leave with a "room" message. It is sent before the list of the sessions + * of the new room, so clearing here cannot drop sessions of the new room. The same room (re-sent for an + * unchanged join) keeps its sessions. After [reset] the room is unknown, so the answer to a leave (empty room) + * clears as well: it drops what late events of the left room added in between. + */ + @Synchronized + fun onRoomResponse(newRoomToken: String?) { + val token = newRoomToken ?: "" + if (token != roomToken) { + sessions.clear() + roomToken = token + } + } + + /** + * The connection leaves the room, got a new signaling session (hello without resume) or lost it + * (no_such_session, no_such_room). Nothing is known about the room any more. + */ + @Synchronized + fun reset() { + sessions.clear() + roomToken = null + } + + /** + * Processes the "join" list of a room event: maps with "sessionid", optional "userid" and optional "user" + * (with "displayname"). A guest joins without "userid" and stays in the registry. + * + * Ignored while no room is known (after [reset] or the answer to a leave): the server sends the answer to a join + * before the list of the sessions, so a join arriving without a room is a late event of a room which was left. + */ + @Synchronized + fun onJoin(joinEvents: List>?) { + if (roomToken.isNullOrEmpty()) return + + joinEvents?.forEach { event -> + val sessionId = event["sessionid"] as? String ?: return@forEach + val participant = ParticipantDto() + val userId = event["userid"] as? String + if (userId != null) { + participant.actorType = ActorType.USERS + participant.actorId = userId + } else { + participant.actorType = ActorType.GUESTS + } + // There is no "user" attribute for guest participants. + val userMap = event["user"] as? Map<*, *> + participant.displayName = userMap?.get("displayname") as? String + sessions[sessionId] = participant + } + } + + fun onLeave(sessionIds: List?) { + sessionIds?.forEach { sessions.remove(it) } + } + + fun contains(sessionId: String?): Boolean = sessionId != null && sessions.containsKey(sessionId) + + /** Display name of the session, or "" if the session is unknown or has no name (e.g. a guest). */ + fun displayNameFor(sessionId: String?): String = sessionId?.let { sessions[it]?.displayName } ?: "" + + /** Snapshot of the sessions of the current room, without the own session. */ + fun sessionIdsExcept(ownSessionId: String?): List = sessions.keys.filter { it != ownSessionId } +} diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index b85b0b2c372..c86f6b2d57f 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -19,8 +19,6 @@ import com.nextcloud.talk.call.e2ee.EncryptionMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.NetworkEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent -import com.nextcloud.talk.models.json.participants.ParticipantDto -import com.nextcloud.talk.models.json.participants.ParticipantDto.ActorType import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto import com.nextcloud.talk.models.json.signaling.settings.FederationSettingsDto import com.nextcloud.talk.models.json.websocket.BaseWebSocketMessageDto @@ -80,7 +78,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU private var currentNormalBackendSession: String? = null private var currentFederation: FederationSettingsDto? = null private var reconnecting = false - private val usersHashMap: HashMap + private val roomSessions = RoomSessionRegistry() private var messagesQueue: MutableList = ArrayList() private val signalingMessageReceiver = ExternalSignalingMessageReceiver() val signalingMessageSender = ExternalSignalingMessageSender() @@ -104,7 +102,6 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU this.conversationUser = conversationUser this.webSocketTicket = webSocketTicket webSocketConnectionHelper = WebSocketConnectionHelper() - usersHashMap = HashMap() isConnected = false signalingMessageReceiver.addListener( SignalingMessageReceiver.EncryptionMessageListener(callEncryptionLifecycle::handleMessage) @@ -272,45 +269,30 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } private fun processRoomJoinMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) { - val joinEventList = eventOverallWebSocketMessage.eventMap?.get("join") as List>? - var internalHashMap: HashMap - var participant: ParticipantDto - for (i in joinEventList!!.indices) { - internalHashMap = joinEventList[i] - val userMap = internalHashMap["user"] as HashMap? - participant = ParticipantDto() - val userId = internalHashMap["userid"] as String? - if (userId != null) { - participant.actorType = ActorType.USERS - participant.actorId = userId - } else { - participant.actorType = ActorType.GUESTS - // FIXME seems to be not given by the HPB: participant.setActorId(); - } - if (userMap != null) { - // There is no "user" attribute for guest participants. - participant.displayName = userMap["displayname"] as String? - } - usersHashMap[internalHashMap["sessionid"] as String?] = participant - } - callEncryptionLifecycle.usersJoined(joinEventList.mapNotNull { it["sessionid"] as String? }) + val joinEventList = eventOverallWebSocketMessage.eventMap?.get("join") as List>? + roomSessions.onJoin(joinEventList) + callEncryptionLifecycle.usersJoined(joinEventList.orEmpty().mapNotNull { it["sessionid"] as String? }) } private fun processRoomLeaveMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) { val leaveEventList = eventOverallWebSocketMessage.eventMap?.get("leave") as List? - for (i in leaveEventList!!.indices) { - usersHashMap.remove(leaveEventList[i]) - } - callEncryptionLifecycle.usersLeft(leaveEventList) + roomSessions.onLeave(leaveEventList) + callEncryptionLifecycle.usersLeft(leaveEventList.orEmpty()) } - fun getUserMap(): HashMap = usersHashMap + /** + * Snapshot of the signaling sessions of the current room, without the own session. + */ + fun getRoomSessionIds(): List = roomSessions.sessionIdsExcept(sessionId) + + fun isSessionInCurrentRoom(session: String?): Boolean = roomSessions.contains(session) @Throws(IOException::class) private fun processJoinedRoomMessage(text: String) { val (_, roomWebSocketMessage) = LoganSquare.parse(text, JoinedRoomOverallWebSocketMessage::class.java) if (roomWebSocketMessage != null) { val alreadyInRoom = roomWebSocketMessage.roomId == currentRoomToken + roomSessions.onRoomResponse(roomWebSocketMessage.roomId) currentRoomToken = roomWebSocketMessage.roomId if (TextUtils.isEmpty(currentRoomToken)) { // The server took the client out of the room, so the keys of that room go with it, like on iOS @@ -343,6 +325,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU resumeId = "" currentRoomToken = "" currentNormalBackendSession = "" + roomSessions.reset() restartWebSocket() } @@ -370,6 +353,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU Log.d(TAG, "Joining the room was rejected, the room session needs to be refreshed") currentRoomToken = "" currentNormalBackendSession = "" + roomSessions.reset() eventBus!!.post(WebSocketCommunicationEvent("roomJoinFailed", HashMap())) } } @@ -412,6 +396,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } else { currentRoomToken = "" currentNormalBackendSession = "" + roomSessions.reset() } if (!TextUtils.isEmpty(currentRoomToken)) { helloHashMap[Globals.ROOM_TOKEN] = currentRoomToken @@ -470,6 +455,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU Log.d(TAG, "sending 'leave room' via websocket") currentNormalBackendSession = "" currentFederation = null + roomSessions.reset() callEncryptionLifecycle.close() sendMessage(message) } else if ( @@ -531,15 +517,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } } - fun getDisplayNameForSession(session: String?): String? { - val participant = usersHashMap[session] - if (participant != null) { - if (participant.displayName != null) { - return participant.displayName - } - } - return "" - } + fun getDisplayNameForSession(session: String?): String = roomSessions.displayNameFor(session) @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(networkEvent: NetworkEvent) { diff --git a/app/src/test/java/com/nextcloud/talk/chat/TypingParticipantTest.kt b/app/src/test/java/com/nextcloud/talk/chat/TypingParticipantTest.kt new file mode 100644 index 00000000000..1e9040f8f8c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/TypingParticipantTest.kt @@ -0,0 +1,158 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class TypingParticipantTest { + + private fun typing(userId: String, session: String) = TypingParticipant(userId, userId, session) { } + + @Test + fun typingOfSessionWhichLeftIsRemoved() { + val participants = mutableMapOf("alice" to typing("alice", "s-alice"), "bob" to typing("bob", "s-bob")) + + val removed = TypingParticipant.removeSessions(participants, listOf("s-alice")) + + assertTrue(removed) + assertEquals(setOf("bob"), participants.keys) + } + + @Test + fun guestTypingIsRemovedWithItsSession() { + val participants = mutableMapOf("s-guest" to typing("s-guest", "s-guest")) + + assertTrue(TypingParticipant.removeSessions(participants, listOf("s-guest"))) + + assertTrue(participants.isEmpty()) + } + + @Test + fun typingOfUserStaysWhileAnotherSessionOfTheUserLeft() { + val participants = mutableMapOf("alice" to typing("alice", "s-alice-phone")) + + val removed = TypingParticipant.removeSessions(participants, listOf("s-alice-laptop")) + + assertFalse(removed) + assertEquals(setOf("alice"), participants.keys) + } + + @Test + fun typingOfUserStaysWhileAnotherSessionOfTheUserStillTypes() { + val alice = typing("alice", "s-alice-phone") + alice.addSession("s-alice-laptop") + val participants = mutableMapOf("alice" to alice) + + val removed = TypingParticipant.removeSessions(participants, listOf("s-alice-phone")) + + assertFalse("the laptop session of alice is still in the room", removed) + assertEquals(setOf("alice"), participants.keys) + } + + @Test + fun typingOfUserIsRemovedWhenAllItsSessionsLeft() { + val alice = typing("alice", "s-alice-phone") + alice.addSession("s-alice-laptop") + val participants = mutableMapOf("alice" to alice) + + assertFalse(TypingParticipant.removeSessions(participants, listOf("s-alice-phone"))) + assertTrue(TypingParticipant.removeSessions(participants, listOf("s-alice-laptop"))) + + assertTrue(participants.isEmpty()) + } + + @Test + fun typingOfUserIsRemovedWhenAllItsSessionsLeftAtOnce() { + val alice = typing("alice", "s-alice-phone") + alice.addSession("s-alice-laptop") + val participants = mutableMapOf("alice" to alice) + + assertTrue(TypingParticipant.removeSessions(participants, listOf("s-alice-phone", "s-alice-laptop"))) + + assertTrue(participants.isEmpty()) + } + + @Test + fun stopOfOneSessionKeepsTypingOfUserUntilLastSessionStops() { + val alice = typing("alice", "s-alice-phone") + alice.addSession("s-alice-laptop") + val participants = mutableMapOf("alice" to alice) + + assertFalse(TypingParticipant.stopTyping(participants, "alice", "s-alice-phone")) + assertEquals(setOf("alice"), participants.keys) + + assertTrue(TypingParticipant.stopTyping(participants, "alice", "s-alice-laptop")) + assertTrue(participants.isEmpty()) + } + + private fun typingWithTimeout(participants: MutableMap, session: String) = + TypingParticipant("alice", "alice", session) { participants.remove("alice", it) } + .also { participants["alice"] = it } + + private fun idle(seconds: Long) = ShadowLooper.idleMainLooper(seconds, TimeUnit.SECONDS) + + @Test + fun stopOfOneSessionLeavesTheOtherSessionToExpireOnItsOwn() { + val participants = mutableMapOf() + val alice = typingWithTimeout(participants, "s-alice-laptop") + idle(10) + alice.addSession("s-alice-phone") + idle(1) + + assertFalse(TypingParticipant.stopTyping(participants, "alice", "s-alice-phone")) + idle(3) + assertEquals("laptop has 1 s left at 14 s", setOf("alice"), participants.keys) + + idle(1) + assertTrue("laptop expired at 15 s", participants.isEmpty()) + } + + @Test + fun stopOfLastSessionRemovesTheParticipantAfterAnotherSessionExpired() { + val participants = mutableMapOf() + val alice = typingWithTimeout(participants, "s-alice-phone") + idle(10) + alice.addSession("s-alice-laptop") + idle(6) + assertEquals("phone expired at 15 s, laptop still types", setOf("alice"), participants.keys) + + assertTrue(TypingParticipant.stopTyping(participants, "alice", "s-alice-laptop")) + + assertTrue(participants.isEmpty()) + } + + @Test + fun repeatedStartOfTheSameSessionRestartsItsTimer() { + var timeUps = 0 + val participants = mutableMapOf() + val alice = TypingParticipant("alice", "alice", "s-alice-phone") { + timeUps++ + participants.remove("alice", it) + } + participants["alice"] = alice + idle(10) + alice.addSession("s-alice-phone") + idle(5) + assertEquals("restarted at 10 s, expires at 25 s", setOf("alice"), participants.keys) + + idle(10) + + assertTrue(participants.isEmpty()) + assertEquals(1, timeUps) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverSessionsLeftTest.kt b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverSessionsLeftTest.kt new file mode 100644 index 00000000000..e1cea9f17dc --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverSessionsLeftTest.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.signaling + +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify + +class SignalingMessageReceiverSessionsLeftTest { + + private lateinit var receiver: SignalingMessageReceiver + private lateinit var listener: SignalingMessageReceiver.ConversationMessageListener + + @Before + fun setUp() { + receiver = object : SignalingMessageReceiver() {} + listener = mock(SignalingMessageReceiver.ConversationMessageListener::class.java) + receiver.addListener(listener) + } + + @Test + fun leaveEventOfTheRoomNotifiesTheSessionsWhichLeft() { + receiver.processEvent(mapOf("target" to "room", "type" to "leave", "leave" to listOf("s-alice", "s-bob"))) + + verify(listener).onSessionsLeft(listOf("s-alice", "s-bob")) + } + + @Test + fun leaveEventWithoutSessionsNotifiesNobody() { + receiver.processEvent(mapOf("target" to "room", "type" to "leave", "leave" to emptyList())) + + verify(listener, never()).onSessionsLeft(emptyList()) + } + + @Test + fun joinEventIsNotALeave() { + receiver.processEvent(mapOf("target" to "room", "type" to "join", "join" to listOf(mapOf("sessionid" to "s")))) + + verify(listener, never()).onSessionsLeft(listOf("s")) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt new file mode 100644 index 00000000000..640182d18b8 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt @@ -0,0 +1,236 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * The registry must hold exactly the signaling sessions of the room the connection is in. The signaling server + * delivers messages addressed to a session no matter which room that session is in, so sessions of other rooms + * must neither receive our typing messages nor be trusted as the sender of a typing message. + */ +@Suppress("TooManyFunctions") +class RoomSessionRegistryTest { + + private fun user(sessionId: String?, userId: String, name: String): Map { + val event = mutableMapOf("userid" to userId, "user" to mapOf("displayname" to name)) + sessionId?.let { event["sessionid"] = it } + return event + } + + private fun guest(sessionId: String) = mapOf("sessionid" to sessionId) + + private fun RoomSessionRegistry.enterRoom(room: String, vararg joins: Map) { + onRoomResponse(room) + onJoin(joins.toList()) + } + + @Test + fun sessionsOfJoinedRoomAreKnownWithDisplayName() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice"), user("s-bob", "bob", "Bob")) + + assertTrue(registry.contains("s-alice")) + assertEquals("Alice", registry.displayNameFor("s-alice")) + assertEquals(setOf("s-alice", "s-bob"), registry.sessionIdsExcept(null).toSet()) + } + + @Test + fun roomResponseOfOtherRoomDropsSessionsOfPreviousRoom() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice-a", "alice", "Alice")) + + registry.enterRoom("roomB", user("s-bob-b", "bob", "Bob")) + + assertEquals( + "no typing message to a session of the previous room", + listOf("s-bob-b"), + registry.sessionIdsExcept(null) + ) + assertFalse("typing message of a session of the previous room is ignored", registry.contains("s-alice-a")) + } + + @Test + fun resetDropsAllSessions() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + registry.reset() + + assertTrue(registry.sessionIdsExcept(null).isEmpty()) + assertFalse(registry.contains("s-alice")) + } + + @Test + fun lateJoinOfTheLeftRoomIsNotAcceptedBeforeTheAnswerToLeave() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + registry.reset() + + registry.onJoin(listOf(user("s-late", "bob", "Bob"))) + + assertTrue("no typing message to the late session", registry.sessionIdsExcept(null).isEmpty()) + assertFalse("typing message of the late session is ignored", registry.contains("s-late")) + + registry.onRoomResponse("") + + registry.onJoin(listOf(user("s-after-leave", "bob", "Bob"))) + + assertTrue("no join is accepted after the answer to the leave", registry.sessionIdsExcept(null).isEmpty()) + } + + /** + * Pauses onJoin() of [lateSession] right after its check of the room (before it stores the session) and runs + * [change] on another thread meanwhile. Without a common lock [change] finishes while onJoin() is paused and + * onJoin() stores the session afterwards; with it, [change] waits for onJoin(). + */ + private fun RoomSessionRegistry.joinPausedWhile(lateSession: String, change: RoomSessionRegistry.() -> Unit) { + val insideJoin = CountDownLatch(1) + val changeMayRun = CountDownLatch(1) + // The map hands the thread over right after onJoin passed its check of the room, before it stores the session. + val pausingEvent = object : HashMap(guest(lateSession)) { + override fun get(key: String): Any? { + insideJoin.countDown() + changeMayRun.await(5, TimeUnit.SECONDS) + return super.get(key) + } + } + val executor = Executors.newFixedThreadPool(2) + try { + val joined = executor.submit { onJoin(listOf(pausingEvent)) } + assertTrue(insideJoin.await(5, TimeUnit.SECONDS)) + val changed = executor.submit { change() } + runCatching { changed.get(500, TimeUnit.MILLISECONDS) } + changeMayRun.countDown() + joined.get(5, TimeUnit.SECONDS) + changed.get(5, TimeUnit.SECONDS) + } finally { + changeMayRun.countDown() + executor.shutdownNow() + } + } + + @Test + fun resetDuringJoinDoesNotLeaveSessionOfTheLeftRoom() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + registry.joinPausedWhile("s-late") { reset() } + + assertFalse("session stored by a join which raced with reset() stays", registry.contains("s-late")) + assertTrue(registry.sessionIdsExcept(null).isEmpty()) + } + + @Test + fun roomResponseDuringJoinDoesNotLeaveSessionOfThePreviousRoom() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + registry.joinPausedWhile("s-late") { onRoomResponse("roomB") } + + assertFalse("session of room A stored after the answer for room B", registry.contains("s-late")) + assertTrue(registry.sessionIdsExcept(null).isEmpty()) + registry.onJoin(listOf(user("s-bob-b", "bob", "Bob"))) + assertEquals(listOf("s-bob-b"), registry.sessionIdsExcept(null)) + } + + @Test + fun roomResponseForSameRoomKeepsSessions() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + registry.onRoomResponse("roomA") + + assertTrue(registry.contains("s-alice")) + } + + @Test + fun joinAndLeaveEventsUpdateTheSessions() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice"), user("s-bob", "bob", "Bob")) + + registry.onLeave(listOf("s-bob")) + registry.onJoin(listOf(user("s-carol", "carol", "Carol"))) + + assertEquals(setOf("s-alice", "s-carol"), registry.sessionIdsExcept(null).toSet()) + } + + @Test + fun resetDropsSessionsUntilNewJoinListArrives() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + registry.reset() + + assertTrue("sessions of the expired connection are gone", registry.sessionIdsExcept(null).isEmpty()) + assertFalse(registry.contains("s-alice")) + + registry.enterRoom("roomA", user("s-alice-new", "alice", "Alice")) + assertEquals(listOf("s-alice-new"), registry.sessionIdsExcept(null)) + } + + @Test + fun realGuestStaysInRegistryWithoutName() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", guest("s-guest")) + + assertTrue(registry.contains("s-guest")) + assertEquals("", registry.displayNameFor("s-guest")) + } + + @Test + fun sessionWithoutJoinIsUnknown() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice", "alice", "Alice")) + + assertFalse(registry.contains("s-stranger")) + assertFalse(registry.contains(null)) + registry.onJoin(listOf(user(null, "alice", "Alice"))) + assertEquals(listOf("s-alice"), registry.sessionIdsExcept(null)) + assertEquals("", registry.displayNameFor("s-stranger")) + } + + @Test + fun ownSessionIsNotATypingTarget() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-me", "me", "Me"), user("s-alice", "alice", "Alice")) + + assertEquals(listOf("s-alice"), registry.sessionIdsExcept("s-me")) + } + + @Test + fun readingWhileWritingFromAnotherThreadIsSafe() { + val registry = RoomSessionRegistry() + val executor = Executors.newFixedThreadPool(2) + val failure = AtomicReference() + val done = CountDownLatch(2) + executor.execute { + runCatching { + repeat(5_000) { i -> + registry.onJoin(listOf(user("s-$i", "u$i", "User $i"))) + if (i % 50 == 0) registry.onRoomResponse("room-$i") + } + }.onFailure { failure.set(it) } + done.countDown() + } + executor.execute { + runCatching { repeat(5_000) { registry.sessionIdsExcept("s-0") } }.onFailure { failure.set(it) } + done.countDown() + } + + assertTrue(done.await(30, TimeUnit.SECONDS)) + executor.shutdownNow() + assertEquals(null, failure.get()) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt new file mode 100644 index 00000000000..3daf9c4b740 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt @@ -0,0 +1,389 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.webrtc + +import android.app.Application +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.application.NextcloudTalkApplicationComponent +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto +import com.nextcloud.talk.signaling.SignalingMessageReceiver +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import okhttp3.OkHttpClient +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.greenrobot.eventbus.EventBus +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList + +private fun sessionOf(room: String) = "session-in-$room" + +private fun joinFrame(session: String, user: String) = + "{\"type\":\"event\",\"event\":{\"target\":\"room\",\"type\":\"join\",\"join\":" + + "[{\"sessionid\":\"$session\",\"userid\":\"$user\",\"user\":{\"displayname\":\"$user\"}}]}}" + +private fun errorFrame(code: String) = "{\"type\":\"error\",\"error\":{\"code\":\"$code\",\"message\":\"t\"}}" + +/** + * The sessions of the room the signaling connection is in, fed by the frames the signaling server sends. A frame + * after the one under test serves as a barrier: the client handles frames in order, so when the barrier session is + * known, everything before it has been handled. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +@Suppress("TooManyFunctions") +class WebSocketInstanceRoomSessionsTest { + + private val server = MockWebServer() + private val serverLog = CopyOnWriteArrayList() + private val serverSockets = CopyOnWriteArrayList() + private val eventBus = EventBus.builder().build() + private val instances = CopyOnWriteArrayList() + private val path = "/t-${UUID.randomUUID()}/" + private val okHttpClient = OkHttpClient() + private var previousApplication: NextcloudTalkApplication? = null + + // the setter of the singleton is protected: only the real application sets it + private val sharedApplicationField = NextcloudTalkApplication::class.java + .getDeclaredField("sharedApplication") + .apply { isAccessible = true } + + @Volatile + private var holdAnswerToLeave = false + + @Before + fun setUp() { + previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication? + sharedApplicationField.set(null, applicationInjecting(okHttpClient, eventBus)) + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.url.encodedPath == path) { + MockResponse.Builder().webSocketUpgrade(ServerListener("c${serverSockets.size + 1}")).build() + } else { + MockResponse(code = HTTP_NOT_FOUND) + } + } + server.start() + } + + @After + fun tearDown() { + instances.forEach { eventBus.unregister(it) } + // close() of the server waits for open sockets + serverSockets.forEach { it.close() } + server.close() + sharedApplicationField.set(null, previousApplication) + } + + @Test + fun switchingToAnotherRoomDropsSessionsOfThePreviousRoom() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") + + waitUntil("sessions of room B") { instance.isSessionInCurrentRoom(sessionOf(ROOM_B)) } + assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertEquals(listOf(sessionOf(ROOM_B)), instance.getRoomSessionIds()) + } + + @Test + fun lateJoinOfTheLeftRoomIsNeitherTrustedNorAddressedWhenTheNextRoomIsEntered() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + // The server answers the leave only after it sent a join event of the room which is being left. + holdAnswerToLeave = true + instance.joinRoomWithRoomTokenAndSession("", "room-session-a") + waitUntil("leave received") { serverLog.contains("c1:left-unanswered") } + serverSockets.first().push(joinFrame(LATE_SESSION, "user-late")) + awaitFramesHandled(instance) + + assertFalse("typing message of the late session is ignored", instance.isSessionInCurrentRoom(LATE_SESSION)) + assertTrue("no typing message to the late session", instance.getRoomSessionIds().isEmpty()) + + serverSockets.first().push("{\"type\":\"room\",\"room\":{\"roomid\":\"\"}}") + instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") + waitUntil("sessions of room B") { instance.isSessionInCurrentRoom(sessionOf(ROOM_B)) } + + assertFalse(instance.isSessionInCurrentRoom(LATE_SESSION)) + assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertEquals(listOf(sessionOf(ROOM_B)), instance.getRoomSessionIds()) + } + + @Test + fun leavingTheRoomDropsTheSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + instance.joinRoomWithRoomTokenAndSession("", "room-session-a") + + waitUntil("room left") { serverLog.contains("c1:left") } + awaitFramesHandled(instance) + assertTrue(instance.getRoomSessionIds().isEmpty()) + } + + @Test + fun resumedSessionKeepsTheSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + instance.restartWebSocket() + waitUntil("resumed hello answered") { serverLog.contains("c2:hello-answered") } + waitUntil("connected again") { instance.isConnected } + sendBarrierJoin(instance) + + assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + } + + @Test + fun newSessionWithoutResumeStartsWithoutSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + instance.clearResumeId() + instance.restartWebSocket() + waitUntil("new session said hello") { serverLog.contains("c2:hello-answered") } + waitUntil("connected again") { instance.isConnected } + awaitFramesHandled(instance) + + assertFalse(serverLog.first { it.startsWith("c2:hello ") }.contains("resumeid")) + assertTrue(instance.getRoomSessionIds().isEmpty()) + } + + @Test + fun alreadyJoinedAnswerKeepsTheSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-call") + waitUntil("second join answered") { serverLog.count { it == "c1:already_joined" } == 1 } + sendBarrierJoin(instance) + + assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + } + + @Test + fun expiredSessionDropsTheSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + serverSockets.first().push(errorFrame("no_such_session")) + waitUntil("new session said hello") { serverLog.contains("c2:hello-answered") } + waitUntil("connected again") { instance.isConnected } + awaitFramesHandled(instance) + + assertTrue(instance.getRoomSessionIds().isEmpty()) + } + + @Test + fun rejectedRoomSessionDropsTheSessions() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + serverSockets.first().push(errorFrame("no_such_room")) + awaitFramesHandled(instance) + + assertTrue(instance.getRoomSessionIds().isEmpty()) + } + + @Test + fun leaveEventRemovesTheSessionAndNotifiesTheListeners() { + val instance = createConnectedInstance() + val listener = mock() + instance.getSignalingMessageReceiver().addListener(listener) + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + + serverSockets.first().push( + "{\"type\":\"event\",\"event\":{\"target\":\"room\",\"type\":\"leave\",\"leave\":" + + "[\"${sessionOf(ROOM_A)}\"]}}" + ) + sendBarrierJoin(instance) + + assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + verify(listener).onSessionsLeft(listOf(sessionOf(ROOM_A))) + } + + @Test + fun accountsDoNotShareSessions() { + val first = createConnectedInstance() + val second = createConnectedInstance() + first.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + second.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") + waitUntil("sessions of room A") { first.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room B") { second.isSessionInCurrentRoom(sessionOf(ROOM_B)) } + + assertEquals(listOf(sessionOf(ROOM_A)), first.getRoomSessionIds()) + assertEquals(listOf(sessionOf(ROOM_B)), second.getRoomSessionIds()) + } + + /** + * A barrier for a connection which is in no room, where a join event is ignored: the leave event of an unknown + * session reaches the listeners, so once it is seen the frames sent before it have been handled. + */ + private fun awaitFramesHandled(instance: WebSocketInstance) { + val listener = mock() + instance.getSignalingMessageReceiver().addListener(listener) + serverSockets.last().push( + "{\"type\":\"event\",\"event\":{\"target\":\"room\",\"type\":\"leave\",\"leave\":[\"$BARRIER\"]}}" + ) + verify(listener, timeout(TIMEOUT_MILLIS)).onSessionsLeft(listOf(BARRIER)) + } + + private fun sendBarrierJoin(instance: WebSocketInstance) { + serverSockets.last().push(joinFrame(BARRIER, "barrier-user")) + waitUntil("barrier handled") { instance.isSessionInCurrentRoom(BARRIER) } + } + + private fun createConnectedInstance(): WebSocketInstance { + val user = User( + userId = "alice", + baseUrl = "https://cloud.example.com", + capabilities = CapabilitiesDto().apply { + spreedCapability = SpreedCapabilityDto(listOf("signaling-v3"), null, "") + } + ) + val created = WebSocketInstance(user, server.url(path).toString(), "ticket") + instances.add(created) + waitUntil("session connected") { created.isConnected } + return created + } + + /** + * The application of the production code injects its components via Dagger; the test hands them the client and + * the event bus it controls instead. + */ + private fun applicationInjecting(client: OkHttpClient, bus: EventBus): NextcloudTalkApplication { + val component = mock( + defaultAnswer = { invocation -> + when (val target = invocation.arguments.firstOrNull()) { + is WebSocketInstance -> { + target.okHttpClient = client + target.eventBus = bus + } + + is WebSocketConnectionHelper -> target.okHttpClient = client + } + null + } + ) + val application = mock() + whenever(application.componentApplication).thenReturn(component) + return application + } + + private fun waitUntil(what: String, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + TIMEOUT_MILLIS + while (!condition()) { + check(System.currentTimeMillis() < deadline) { "Timed out waiting for $what, server log: $serverLog" } + Thread.sleep(POLL_MILLIS) + } + } + + /** + * Behaves like the signaling server: a join of a room is answered with the "room" message, followed by the + * join event of a session of that room; joining the same room again is an "already_joined" error. + */ + private inner class ServerListener(private val name: String) : WebSocketListener() { + private var webSocket: WebSocket? = null + private var joinedRoom: String? = null + + init { + serverSockets.add(this) + } + + fun push(frame: String) { + webSocket!!.send(frame) + } + + fun close() { + webSocket?.close(NORMAL_CLOSURE, null) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + this.webSocket = webSocket + when { + text.contains("\"type\":\"hello\"") -> { + serverLog.add("$name:hello $text") + webSocket.send( + "{\"type\":\"hello\",\"hello\":{\"resumeid\":\"resume-$name\",\"sessionid\":\"own-$name\"}}" + ) + serverLog.add("$name:hello-answered") + } + + text.contains("\"type\":\"room\"") -> { + val room = Regex("\"roomid\":\"([^\"]*)\"").find(text)!!.groupValues[1] + when { + room.isEmpty() && holdAnswerToLeave -> { + joinedRoom = null + serverLog.add("$name:left-unanswered") + } + + room.isEmpty() -> { + joinedRoom = null + webSocket.send("{\"type\":\"room\",\"room\":{\"roomid\":\"\"}}") + serverLog.add("$name:left") + } + + room == joinedRoom -> { + webSocket.send(errorFrame("already_joined")) + serverLog.add("$name:already_joined") + } + + else -> { + joinedRoom = room + webSocket.send( + "{\"type\":\"room\",\"room\":{\"roomid\":\"$room\",\"properties\":{\"name\":\"n\"}}}" + ) + webSocket.send(joinFrame(sessionOf(room), "user-in-$room")) + } + } + } + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(NORMAL_CLOSURE, null) + } + } + + companion object { + private const val ROOM_A = "roomA" + private const val ROOM_B = "roomB" + private const val BARRIER = "barrier-session" + private const val LATE_SESSION = "late-session-of-room-a" + private const val NORMAL_CLOSURE = 1000 + private const val HTTP_NOT_FOUND = 404 + private const val TIMEOUT_MILLIS = 10_000L + private const val POLL_MILLIS = 20L + } +} From e903147451ae8f409b546985582a7bd478a69e39 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 30 Oct 2026 14:51:04 +0100 Subject: [PATCH 2/3] fix(chat): accept typing only for the room the connection is in When the connection switches rooms without leaving first (e.g. during a call), the sessions of the previous room stay in the registry until the server answers the join of the new room. A typing message from such a session was shown in the conversation of the new room. The chat now checks the session together with its own room token, so a session counts only for the room the connection is in. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 4 ++-- .../talk/webrtc/RoomSessionRegistry.kt | 7 ++++++ .../talk/webrtc/WebSocketInstance.kt | 6 +++++ .../talk/webrtc/RoomSessionRegistryTest.kt | 12 ++++++++++ .../WebSocketInstanceRoomSessionsTest.kt | 24 +++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index bb082a3bbbb..4345a3d45ad 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -506,7 +506,7 @@ class ChatActivity : if (isTypingStatusEnabled() && conversationUser.userId != userIdOrGuestSession && - webSocketInstance?.isSessionInCurrentRoom(session) == true + webSocketInstance?.isSessionInRoom(session, roomToken) == true ) { var displayName = webSocketInstance?.getDisplayNameForSession(session).orEmpty() if (displayName == "") { @@ -536,7 +536,7 @@ class ChatActivity : if (isTypingStatusEnabled() && conversationUser.userId != userId && - webSocketInstance?.isSessionInCurrentRoom(session) == true + webSocketInstance?.isSessionInRoom(session, roomToken) == true ) { runOnUiThread { if (TypingParticipant.stopTyping(typingParticipants, userId ?: session, session)) { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt index f592cb4bce1..bc550840424 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt @@ -88,6 +88,13 @@ class RoomSessionRegistry { fun contains(sessionId: String?): Boolean = sessionId != null && sessions.containsKey(sessionId) + /** + * Whether [sessionId] is a session of the room [room]. While a join of another room is not answered yet, the + * sessions are still those of the previous room, so the session alone does not tell which room it belongs to. + */ + @Synchronized + fun contains(sessionId: String?, room: String): Boolean = room == roomToken && contains(sessionId) + /** Display name of the session, or "" if the session is unknown or has no name (e.g. a guest). */ fun displayNameFor(sessionId: String?): String = sessionId?.let { sessions[it]?.displayName } ?: "" diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index c86f6b2d57f..123fdb0b390 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -287,6 +287,12 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU fun isSessionInCurrentRoom(session: String?): Boolean = roomSessions.contains(session) + /** + * Whether [session] is in the room [roomToken] and the connection is in that room as well. A typing message is + * only trusted for the conversation of the room the session was seen in. + */ + fun isSessionInRoom(session: String?, roomToken: String): Boolean = roomSessions.contains(session, roomToken) + @Throws(IOException::class) private fun processJoinedRoomMessage(text: String) { val (_, roomWebSocketMessage) = LoganSquare.parse(text, JoinedRoomOverallWebSocketMessage::class.java) diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt index 640182d18b8..5e24b9f3cc6 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/RoomSessionRegistryTest.kt @@ -61,6 +61,18 @@ class RoomSessionRegistryTest { assertFalse("typing message of a session of the previous room is ignored", registry.contains("s-alice-a")) } + @Test + fun sessionIsOnlyInTheRoomTheConnectionIsIn() { + val registry = RoomSessionRegistry() + registry.enterRoom("roomA", user("s-alice-a", "alice", "Alice")) + + assertTrue(registry.contains("s-alice-a", "roomA")) + assertFalse( + "typing message of room A is ignored by the conversation of room B before room B is answered", + registry.contains("s-alice-a", "roomB") + ) + } + @Test fun resetDropsAllSessions() { val registry = RoomSessionRegistry() diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt index 3daf9c4b740..6324c5c4069 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt @@ -72,6 +72,9 @@ class WebSocketInstanceRoomSessionsTest { @Volatile private var holdAnswerToLeave = false + @Volatile + private var holdAnswerToJoin = false + @Before fun setUp() { previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication? @@ -109,6 +112,25 @@ class WebSocketInstanceRoomSessionsTest { assertEquals(listOf(sessionOf(ROOM_B)), instance.getRoomSessionIds()) } + @Test + fun sessionOfThePreviousRoomIsNotTrustedForTheNextRoomBeforeItsJoinIsAnswered() { + val instance = createConnectedInstance() + instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") + waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + // Switching rooms without leaving first (e.g. during a call): the sessions of room A stay until the answer. + holdAnswerToJoin = true + instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") + waitUntil("join received") { serverLog.contains("c1:joined-unanswered") } + sendBarrierJoin(instance) + + assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertFalse( + "typing message of room A is ignored by the conversation of room B", + instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_B) + ) + assertTrue(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A)) + } + @Test fun lateJoinOfTheLeftRoomIsNeitherTrustedNorAddressedWhenTheNextRoomIsEntered() { val instance = createConnectedInstance() @@ -354,6 +376,8 @@ class WebSocketInstanceRoomSessionsTest { serverLog.add("$name:left") } + holdAnswerToJoin -> serverLog.add("$name:joined-unanswered") + room == joinedRoom -> { webSocket.send(errorFrame("already_joined")) serverLog.add("$name:already_joined") From 585854b78eee5db0ca7a9e21a77725d4c4a0b352 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 30 Oct 2026 15:07:20 +0100 Subject: [PATCH 3/3] refactor(chat): simplify typing session cleanup and check sessions by room in tests - isSessionInCurrentRoom() was only used by tests after the room token check, so it is removed and the tests use isSessionInRoom(), the check the chat makes. - removeSessions() of TypingParticipant forgets the sessions in a plain loop instead of inside a filter predicate. - RoomSessionRegistry documents why a resumed session keeps the sessions: the signaling server stores the messages of a disconnected session, including join and leave events, and sends them on resume. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/chat/TypingParticipant.kt | 13 ++++-- .../talk/webrtc/RoomSessionRegistry.kt | 3 ++ .../talk/webrtc/WebSocketInstance.kt | 2 - .../WebSocketInstanceRoomSessionsTest.kt | 45 +++++++++---------- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt index 0ffa7b4577c..7664b483569 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/TypingParticipant.kt @@ -71,11 +71,16 @@ class TypingParticipant( * left (a user may type with several sessions). Returns whether any participant was removed. */ fun removeSessions(participants: MutableMap, sessions: Collection): Boolean { - val left = participants.filterValues { participant -> - participant.forgetSessions(sessions) && participant.timers.isEmpty() + var removed = false + val iterator = participants.values.iterator() + while (iterator.hasNext()) { + val participant = iterator.next() + if (participant.forgetSessions(sessions) && participant.timers.isEmpty()) { + iterator.remove() + removed = true + } } - left.keys.forEach { participants.remove(it) } - return left.isNotEmpty() + return removed } private const val TYPING_DURATION_TO_HIDE_TYPING_MESSAGE = 15000L diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt index bc550840424..ecfe492031d 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/RoomSessionRegistry.kt @@ -47,6 +47,9 @@ class RoomSessionRegistry { /** * The connection leaves the room, got a new signaling session (hello without resume) or lost it * (no_such_session, no_such_room). Nothing is known about the room any more. + * + * A resumed session keeps the registry: the server stores the messages of a disconnected session, including + * the join and leave events, and sends them on resume. */ @Synchronized fun reset() { diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 123fdb0b390..807e166f13d 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -285,8 +285,6 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU */ fun getRoomSessionIds(): List = roomSessions.sessionIdsExcept(sessionId) - fun isSessionInCurrentRoom(session: String?): Boolean = roomSessions.contains(session) - /** * Whether [session] is in the room [roomToken] and the connection is in that room as well. A typing message is * only trusted for the conversation of the room the session was seen in. diff --git a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt index 6324c5c4069..62177f9ddf9 100644 --- a/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt +++ b/app/src/test/java/com/nextcloud/talk/webrtc/WebSocketInstanceRoomSessionsTest.kt @@ -103,12 +103,12 @@ class WebSocketInstanceRoomSessionsTest { fun switchingToAnotherRoomDropsSessionsOfThePreviousRoom() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") - waitUntil("sessions of room B") { instance.isSessionInCurrentRoom(sessionOf(ROOM_B)) } - assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + waitUntil("sessions of room B") { instance.isSessionInRoom(sessionOf(ROOM_B), ROOM_B) } + assertFalse(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_B)) assertEquals(listOf(sessionOf(ROOM_B)), instance.getRoomSessionIds()) } @@ -116,14 +116,13 @@ class WebSocketInstanceRoomSessionsTest { fun sessionOfThePreviousRoomIsNotTrustedForTheNextRoomBeforeItsJoinIsAnswered() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } // Switching rooms without leaving first (e.g. during a call): the sessions of room A stay until the answer. holdAnswerToJoin = true instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") waitUntil("join received") { serverLog.contains("c1:joined-unanswered") } sendBarrierJoin(instance) - assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) assertFalse( "typing message of room A is ignored by the conversation of room B", instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_B) @@ -135,7 +134,7 @@ class WebSocketInstanceRoomSessionsTest { fun lateJoinOfTheLeftRoomIsNeitherTrustedNorAddressedWhenTheNextRoomIsEntered() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } // The server answers the leave only after it sent a join event of the room which is being left. holdAnswerToLeave = true instance.joinRoomWithRoomTokenAndSession("", "room-session-a") @@ -143,15 +142,15 @@ class WebSocketInstanceRoomSessionsTest { serverSockets.first().push(joinFrame(LATE_SESSION, "user-late")) awaitFramesHandled(instance) - assertFalse("typing message of the late session is ignored", instance.isSessionInCurrentRoom(LATE_SESSION)) + assertFalse("typing message of the late session is ignored", instance.isSessionInRoom(LATE_SESSION, ROOM_A)) assertTrue("no typing message to the late session", instance.getRoomSessionIds().isEmpty()) serverSockets.first().push("{\"type\":\"room\",\"room\":{\"roomid\":\"\"}}") instance.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") - waitUntil("sessions of room B") { instance.isSessionInCurrentRoom(sessionOf(ROOM_B)) } + waitUntil("sessions of room B") { instance.isSessionInRoom(sessionOf(ROOM_B), ROOM_B) } - assertFalse(instance.isSessionInCurrentRoom(LATE_SESSION)) - assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertFalse(instance.isSessionInRoom(LATE_SESSION, ROOM_B)) + assertFalse(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_B)) assertEquals(listOf(sessionOf(ROOM_B)), instance.getRoomSessionIds()) } @@ -159,7 +158,7 @@ class WebSocketInstanceRoomSessionsTest { fun leavingTheRoomDropsTheSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } instance.joinRoomWithRoomTokenAndSession("", "room-session-a") @@ -172,21 +171,21 @@ class WebSocketInstanceRoomSessionsTest { fun resumedSessionKeepsTheSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } instance.restartWebSocket() waitUntil("resumed hello answered") { serverLog.contains("c2:hello-answered") } waitUntil("connected again") { instance.isConnected } sendBarrierJoin(instance) - assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertTrue(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A)) } @Test fun newSessionWithoutResumeStartsWithoutSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } instance.clearResumeId() instance.restartWebSocket() @@ -202,20 +201,20 @@ class WebSocketInstanceRoomSessionsTest { fun alreadyJoinedAnswerKeepsTheSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-call") waitUntil("second join answered") { serverLog.count { it == "c1:already_joined" } == 1 } sendBarrierJoin(instance) - assertTrue(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertTrue(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A)) } @Test fun expiredSessionDropsTheSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } serverSockets.first().push(errorFrame("no_such_session")) waitUntil("new session said hello") { serverLog.contains("c2:hello-answered") } @@ -229,7 +228,7 @@ class WebSocketInstanceRoomSessionsTest { fun rejectedRoomSessionDropsTheSessions() { val instance = createConnectedInstance() instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } serverSockets.first().push(errorFrame("no_such_room")) awaitFramesHandled(instance) @@ -243,7 +242,7 @@ class WebSocketInstanceRoomSessionsTest { val listener = mock() instance.getSignalingMessageReceiver().addListener(listener) instance.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") - waitUntil("sessions of room A") { instance.isSessionInCurrentRoom(sessionOf(ROOM_A)) } + waitUntil("sessions of room A") { instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } serverSockets.first().push( "{\"type\":\"event\",\"event\":{\"target\":\"room\",\"type\":\"leave\",\"leave\":" + @@ -251,7 +250,7 @@ class WebSocketInstanceRoomSessionsTest { ) sendBarrierJoin(instance) - assertFalse(instance.isSessionInCurrentRoom(sessionOf(ROOM_A))) + assertFalse(instance.isSessionInRoom(sessionOf(ROOM_A), ROOM_A)) verify(listener).onSessionsLeft(listOf(sessionOf(ROOM_A))) } @@ -261,8 +260,8 @@ class WebSocketInstanceRoomSessionsTest { val second = createConnectedInstance() first.joinRoomWithRoomTokenAndSession(ROOM_A, "room-session-a") second.joinRoomWithRoomTokenAndSession(ROOM_B, "room-session-b") - waitUntil("sessions of room A") { first.isSessionInCurrentRoom(sessionOf(ROOM_A)) } - waitUntil("sessions of room B") { second.isSessionInCurrentRoom(sessionOf(ROOM_B)) } + waitUntil("sessions of room A") { first.isSessionInRoom(sessionOf(ROOM_A), ROOM_A) } + waitUntil("sessions of room B") { second.isSessionInRoom(sessionOf(ROOM_B), ROOM_B) } assertEquals(listOf(sessionOf(ROOM_A)), first.getRoomSessionIds()) assertEquals(listOf(sessionOf(ROOM_B)), second.getRoomSessionIds()) @@ -283,7 +282,7 @@ class WebSocketInstanceRoomSessionsTest { private fun sendBarrierJoin(instance: WebSocketInstance) { serverSockets.last().push(joinFrame(BARRIER, "barrier-user")) - waitUntil("barrier handled") { instance.isSessionInCurrentRoom(BARRIER) } + waitUntil("barrier handled") { instance.getRoomSessionIds().contains(BARRIER) } } private fun createConnectedInstance(): WebSocketInstance {