diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index fda53cfed3..8388547b2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -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()); + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessorTest.kt new file mode 100644 index 0000000000..efdc7beb21 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessorTest.kt @@ -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() + } +}