From 5bb58604de3e23fe9b9c2bc7557d6b730114261a Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Sun, 23 Aug 2026 06:07:05 -0400 Subject: [PATCH 1/2] fix(call): recover from stale room session instead of ringing forever Rejoining a call after a network drop reuses the cached room session from ApplicationWideCurrentRoomHolder. If the server reaped that session in the meantime (sessions that stop pinging are invalidated), the signaling server rejects the join with "no_such_room". That error was only logged, and the calling timeout is armed only after a successful join, so the call UI showed "Ringing" forever with no way out. - WebSocketInstance: handle "no_such_room" by clearing the cached room join state (so a retry actually sends) and posting a roomJoinFailed event - CallActivity: on roomJoinFailed, drop the cached session and re-run the joinRoom API to fetch a fresh one, retrying the join; after two failed refreshes, show an error and leave instead of ringing forever; never touch an already established call Assisted-by: opencode:ox-alpha Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 35 ++++++++++++++++ .../talk/webrtc/WebSocketInstance.kt | 7 ++++ app/src/main/res/values/strings.xml | 1 + .../CallActivityRoomJoinRefreshTest.kt | 41 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 app/src/test/java/com/nextcloud/talk/activities/CallActivityRoomJoinRefreshTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index a27a42c0b7..f11f3b557d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -41,6 +41,7 @@ import android.view.MotionEvent import android.view.OrientationEventListener import android.view.View import android.view.View.OnTouchListener +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog @@ -311,6 +312,7 @@ class CallActivity : CallBaseActivity() { private var webSocketClient: WebSocketInstance? = null private var webSocketConnectionHelper: WebSocketConnectionHelper? = null private var joinRoomInitiated = false + private var roomJoinRefreshes = 0 private var hasMCU = false private var hasExternalSignalingServer = false private var conversationPassword: String? = null @@ -1590,6 +1592,31 @@ class CallActivity : CallBaseActivity() { } } + /** + * Joining the room for a call was rejected because the cached room session is stale (reaped by the server). + * Drops the cached session so [joinRoomAndCall] fetches a fresh one via the joinRoom API, and retries a few + * times; otherwise the call UI would show "Ringing" forever, as the calling timeout is only armed after a + * successful join. + */ + private fun handleRoomJoinFailed() { + Log.d(TAG, "onMessageEvent 'roomJoinFailed'") + if (!shouldRefreshRoomSession(currentCallStatus, roomJoinRefreshes)) { + if (currentCallStatus !== CallStatus.IN_CONVERSATION) { + Log.e(TAG, "Joining the room for the call failed repeatedly, leaving") + runOnUiThread { + Toast.makeText(context, R.string.nc_call_join_failed, Toast.LENGTH_LONG).show() + finish() + } + } + return + } + roomJoinRefreshes++ + Log.d(TAG, "Refreshing the room session and retrying the join ($roomJoinRefreshes/$MAX_ROOM_JOIN_REFRESHES)") + ApplicationWideCurrentRoomHolder.getInstance().session = "" + callSession = null + joinRoomAndCall() + } + private fun callOrJoinRoomViaWebSocket() { if (hasExternalSignalingServer) { webSocketClient!!.joinRoomWithRoomTokenAndSession( @@ -1900,10 +1927,13 @@ class CallActivity : CallBaseActivity() { } startSendingNick() if (webSocketCommunicationEvent.getHashMap()!!["roomToken"] == roomToken) { + roomJoinRefreshes = 0 performCall() } } + "roomJoinFailed" -> handleRoomJoinFailed() + "recordingStatus" -> { Log.d(TAG, "onMessageEvent 'recordingStatus'") if (webSocketCommunicationEvent.getHashMap()!!.containsKey(KEY_RECORDING_STATE)) { @@ -3225,6 +3255,11 @@ class CallActivity : CallBaseActivity() { private const val CALLING_TIMEOUT: Long = 45000 private const val PULSE_ANIMATION_DURATION: Int = 310 + private const val MAX_ROOM_JOIN_REFRESHES: Int = 2 + + internal fun shouldRefreshRoomSession(callStatus: CallStatus?, refreshesDone: Int): Boolean = + callStatus !== CallStatus.IN_CONVERSATION && refreshesDone < MAX_ROOM_JOIN_REFRESHES + private const val DELAY_ON_ERROR_STOP_THRESHOLD: Int = 16 private const val SESSION_ID_PREFFIX_END: Int = 4 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 2664a021da..177b46b04b 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -311,6 +311,13 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU restartWebSocket() } else if ("hello_expected" == message.code) { restartWebSocket() + } else if ("no_such_room" == message.code) { + // The room session is stale (e.g. reaped by the server). Clear the cached join state so a retry + // actually sends, and let the call UI fetch a fresh room session via the joinRoom API. + Log.d(TAG, "Joining the room was rejected, the room session needs to be refreshed") + currentRoomToken = "" + currentNormalBackendSession = "" + eventBus!!.post(WebSocketCommunicationEvent("roomJoinFailed", null)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 891d2801d7..f568f5b1c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,7 @@ How to translate with transifex: Guest Public conversation No response in 45 seconds, tap to try again + Could not join the call. Please try again. Reconnecting … Currently offline, please check your connectivity Leaving call … diff --git a/app/src/test/java/com/nextcloud/talk/activities/CallActivityRoomJoinRefreshTest.kt b/app/src/test/java/com/nextcloud/talk/activities/CallActivityRoomJoinRefreshTest.kt new file mode 100644 index 0000000000..56ccec609e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/CallActivityRoomJoinRefreshTest.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.activities + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Room session refresh decisions ([CallActivity.shouldRefreshRoomSession]). + * + * Joining a call with a stale room session (reaped by the server) is rejected with "no_such_room". The cached + * session must be dropped and a fresh one fetched via the joinRoom API — but only while the call is still being + * set up (a stray error must never disturb an established call) and only a bounded number of times (otherwise the + * UI would retry forever instead of failing visibly). + */ +class CallActivityRoomJoinRefreshTest { + + @Test + fun `stale session is refreshed while the call is being set up`() { + assertTrue(CallActivity.shouldRefreshRoomSession(CallStatus.CONNECTING, 0)) + assertTrue(CallActivity.shouldRefreshRoomSession(CallStatus.JOINED, 0)) + assertTrue(CallActivity.shouldRefreshRoomSession(CallStatus.RECONNECTING, 0)) + } + + @Test + fun `session is never refreshed once in conversation`() { + assertFalse(CallActivity.shouldRefreshRoomSession(CallStatus.IN_CONVERSATION, 0)) + } + + @Test + fun `gives up after the maximum number of refreshes`() { + assertTrue(CallActivity.shouldRefreshRoomSession(CallStatus.CONNECTING, 1)) + assertFalse(CallActivity.shouldRefreshRoomSession(CallStatus.CONNECTING, 2)) + assertFalse(CallActivity.shouldRefreshRoomSession(CallStatus.CONNECTING, 3)) + } +} From 9a07f60aae758c9306fe220a1a3419c363b874cd Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Sun, 23 Aug 2026 15:36:58 -0400 Subject: [PATCH 2/2] fix(call): post roomJoinFailed with an empty payload map CallActivity.onMessageEvent only processes WebSocketCommunicationEvents whose HashMap is non-null, so the roomJoinFailed event posted with null was silently dropped and the room session refresh never ran. Assisted-by: opencode:ox-alpha Signed-off-by: Tarek Loubani --- .../main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 177b46b04b..0242902aac 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -317,7 +317,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 = "" - eventBus!!.post(WebSocketCommunicationEvent("roomJoinFailed", null)) + eventBus!!.post(WebSocketCommunicationEvent("roomJoinFailed", HashMap())) } } }