diff --git a/.changeset/fix-room-connect-failure-state.md b/.changeset/fix-room-connect-failure-state.md new file mode 100644 index 00000000..57cf6b12 --- /dev/null +++ b/.changeset/fix-room-connect-failure-state.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fixed Room getting stuck in CONNECTING state after failed connect attempts. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index 658d910b..700643c2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -566,7 +566,12 @@ constructor( } connectJob.join() - error?.let { throw it } + error?.let { + if (it !is CancellationException) { + handleDisconnect(DisconnectReason.JOIN_FAILURE) + } + throw it + } } /** diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt index 37296965..a6ffc0d4 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt @@ -269,4 +269,90 @@ class RoomTest { assertEquals(update.sid, sid.sid) } + + @Test + fun connectFailureResetsStateToDisconnected() = runTest { + val connectException = RuntimeException("Connection failed") + rtcEngine.stub { + onBlocking { rtcEngine.join(any(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + throw connectException + } + } + rtcEngine.stub { + onBlocking { rtcEngine.client } + .doReturn(Mockito.mock(SignalClient::class.java)) + } + + val eventCollector = EventCollector(room.events, coroutineRule.scope) + + var caughtException: Throwable? = null + try { + room.connect( + url = TestData.EXAMPLE_URL, + token = "", + ) + } catch (e: Throwable) { + caughtException = e + } + + val events = eventCollector.stopCollecting() + + // Verify exception was thrown (check message since coroutines may wrap exceptions) + assertEquals("Connection failed", caughtException?.message) + + // Verify room state is reset to DISCONNECTED + assertEquals(Room.State.DISCONNECTED, room.state) + + // Verify Disconnected event was posted with JOIN_FAILURE reason + val disconnectedEvents = events.filterIsInstance() + assertEquals(1, disconnectedEvents.size) + assertEquals(DisconnectReason.JOIN_FAILURE, disconnectedEvents[0].reason) + } + + @Test + fun connectRetryAfterFailureSucceeds() = runTest { + val connectException = RuntimeException("Connection failed") + var shouldFail = true + + rtcEngine.stub { + onBlocking { rtcEngine.join(any(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + if (shouldFail) { + throw connectException + } + room.onJoinResponse(TestData.JOIN.join) + TestData.JOIN.join + } + } + rtcEngine.stub { + onBlocking { rtcEngine.client } + .doReturn(Mockito.mock(SignalClient::class.java)) + } + + // First connect attempt fails + try { + room.connect( + url = TestData.EXAMPLE_URL, + token = "", + ) + } catch (e: Throwable) { + // Expected + } + + // Verify room is in DISCONNECTED state after failure + assertEquals(Room.State.DISCONNECTED, room.state) + + // Second connect attempt should succeed + shouldFail = false + room.connect( + url = TestData.EXAMPLE_URL, + token = "", + ) + + // Verify room connected successfully + val roomInfo = TestData.JOIN.join.room + assertEquals(roomInfo.name, room.name) + assertEquals(Room.Sid(roomInfo.sid), room.sid) + } }