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..0242902aac 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", HashMap()))
}
}
}
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))
+ }
+}