Resolve the call log entry for outgoing calls that fail during setup.

This commit is contained in:
Cody Henthorne
2026-09-23 16:00:20 -04:00
committed by Michelle Tang
parent 0d87ed08c2
commit 5bff46f586
2 changed files with 162 additions and 6 deletions
@@ -257,9 +257,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) {
RemotePeer activePeer = currentState.getCallInfoState().getActivePeer();
if (activePeer != null) {
webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer,
true,
currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled());
markNotAccepted(currentState, activePeer.getCallId());
}
return activeCallDelegate.handleLocalHangup(currentState);
@@ -280,9 +278,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
callEndReason == CallManager.CallEndReason.TIMEOUT ||
callEndReason == CallManager.CallEndReason.REMOTE_GLARE))
{
webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer,
true,
currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled());
markNotAccepted(currentState, remotePeer.getCallId());
}
return activeCallDelegate.handleEndedRemote(currentState, callEndReason, remotePeer);
@@ -290,11 +286,15 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
@Override
protected @NonNull WebRtcServiceState handleEnded(@NonNull WebRtcServiceState currentState, @NonNull CallManager.CallEndReason callEndReason, @NonNull RemotePeer remotePeer) {
markNotAccepted(currentState, remotePeer.getCallId());
return activeCallDelegate.handleEnded(currentState, callEndReason, remotePeer);
}
@Override
protected @NonNull WebRtcServiceState handleSetupFailure(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) {
markNotAccepted(currentState, callId);
return activeCallDelegate.handleSetupFailure(currentState, callId);
}
@@ -307,4 +307,18 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
return callSetupDelegate.handleSetEnableVideo(currentState, enable);
}
/**
* Notifies linked devices the active outgoing call ended unaccepted. No-op if {@code callId} isn't the active peer's.
*/
private void markNotAccepted(@NonNull WebRtcServiceState currentState, @NonNull CallId callId) {
RemotePeer activePeer = currentState.getCallInfoState().getActivePeer();
if (activePeer == null || !activePeer.getCallId().equals(callId)) {
return;
}
webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer,
true,
currentState.getCallSetupState(activePeer).isEnableVideoOnCreate() || currentState.getLocalDeviceState().getCameraState().isEnabled());
}
}
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc
import android.app.Application
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter
import org.thoughtcrime.securesms.ringrtc.RemotePeer
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState
import org.thoughtcrime.securesms.testutil.SystemOutLogger
/**
* State transition tests for the outgoing 1:1 call processor, focused on calls that die during setup
* and still have an ongoing call log entry to resolve.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class, instrumentedPackages = ["org.signal.ringrtc"])
class OutgoingCallActionProcessorTest {
companion object {
private val CALL_ID = CallId(42L)
private val OTHER_CALL_ID = CallId(43L)
@JvmStatic
@BeforeClass
fun setUpClass() {
Log.initialize(SystemOutLogger())
}
}
private val callManager: CallManager = mockk(relaxed = true)
private val webRtcInteractor: WebRtcInteractor = mockk(relaxed = true)
private val router: OutgoingVideoSourceRouter = mockk(relaxed = true)
private val localSink: BroadcastVideoSink = mockk()
private val processor = OutgoingCallActionProcessor(webRtcInteractor)
@Before
fun setUp() {
every { webRtcInteractor.callManager } returns callManager
every { router.cameraState } returns CameraState(CameraState.Direction.FRONT, 2)
}
@Test
fun `Given a dialing video call, when it ends with a connection failure, then I expect a not accepted sync event`() {
val state = outgoingDialingCall()
val activePeer = state.callInfoState.requireActivePeer()
processor.handleEnded(state, CallManager.CallEndReason.CONNECTION_FAILURE, activePeer)
verify { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, true, true) }
}
@Test
fun `Given a dialing audio call, when it times out, then I expect a not accepted sync event`() {
val state = outgoingDialingCall(videoCall = false)
val activePeer = state.callInfoState.requireActivePeer()
processor.handleEnded(state, CallManager.CallEndReason.TIMEOUT, activePeer)
verify { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, true, false) }
}
@Test
fun `Given a dialing video call, when setup fails, then I expect a not accepted sync event`() {
val state = outgoingDialingCall()
val activePeer = state.callInfoState.requireActivePeer()
processor.handleSetupFailure(state, CALL_ID)
verify { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, true, true) }
}
@Test
fun `Given a dialing video call, when an unrelated call ends, then I expect no sync event`() {
val state = outgoingDialingCall()
processor.handleEnded(state, CallManager.CallEndReason.CONNECTION_FAILURE, RemotePeer(RecipientId.from(2L), OTHER_CALL_ID))
verify(exactly = 0) { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(any(), any(), any()) }
}
@Test
fun `Given a dialing video call, when an unrelated call fails setup, then I expect no sync event`() {
val state = outgoingDialingCall()
processor.handleSetupFailure(state, OTHER_CALL_ID)
verify(exactly = 0) { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(any(), any(), any()) }
}
@Test
fun `Given a video call that has not started its camera yet, when setup fails, then I expect a video sync event`() {
val state = outgoingDialingCall(videoCall = true, cameraEnabled = false)
val activePeer = state.callInfoState.requireActivePeer()
processor.handleSetupFailure(state, CALL_ID)
verify { webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, true, true) }
}
private fun outgoingDialingCall(videoCall: Boolean = true, cameraEnabled: Boolean = videoCall): WebRtcServiceState {
val peer = RemotePeer(RecipientId.from(1L), CALL_ID)
peer.dialing()
return WebRtcServiceState(processor)
.builder()
.changeCallInfoState()
.callState(WebRtcViewModel.State.CALL_OUTGOING)
.activePeer(peer)
.commit()
.changeCallSetupState(CALL_ID)
.enableVideoOnCreate(videoCall)
.commit()
.changeLocalDeviceState()
.cameraState(if (cameraEnabled) CameraState(CameraState.Direction.FRONT, 2) else CameraState.UNKNOWN)
.commit()
.changeVideoState()
.router(router)
.localSink(localSink)
.commit()
.build()
}
}