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)) + } +}