From dec7b8207a8805a2f57a933966e3481bd120fb5a Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 22 Sep 2026 22:15:50 +0300 Subject: [PATCH] Preserve multi-device call signaling with legacy tunnel compatibility --- docs/CALL_TUNNEL.md | 23 +++ .../signal/manager/helper/CallManager.java | 104 ++++++++--- .../helper/IncomingMessageHandler.java | 14 +- .../helper/CallManagerSignalingTest.java | 176 ++++++++++++++++++ 4 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index b51bc5dd..017d524a 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -31,6 +31,29 @@ to JSON-RPC clients, which use them to connect audio via platform APIs. --- +## Multi-device compatibility + +A tunnel supporting device-aware hangup/busy handling includes +`"signalingVersion": 2` in its `ready` event. The separate +`{"type":"signalingCapabilities","version":2}` event is also accepted for +compatibility with tunnels advertising capabilities separately. + +For this capability, signal-cli forwards incoming `receivedHangup` messages with +`senderDeviceId`, numeric `hangupType` (0 normal, 1 accepted elsewhere, 2 declined +elsewhere, 3 busy elsewhere, 4 permission needed), and `deviceId` identifying the +accepted/declined device. `receivedBusy` carries `senderDeviceId`. RingRTC decides +which device connection should end. + +Outgoing signaling events may include `receiverDeviceId`: an integer targets +one device, explicit null broadcasts, and an absent field retains the legacy +call target. A `sendHangup` also carries `deviceId` for the accepted/declined +device; this is distinct from its delivery target. + +An older tunnel keeps the existing direct hangup/busy cleanup and notification +filtering. An older CLI ignores the added capability and target fields. Either +project can upgrade independently; complete multi-device behavior requires both. +The account's local device-ID fix is independent of this protocol extension. + ## Spawning the Tunnel For each call, signal-cli: diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index de97899e..29b9ba49 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -276,8 +276,12 @@ public class CallManager implements AutoCloseable { answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); - state.deviceId = deviceId; - state.state = CallInfo.State.CONNECTING; + if (!state.multiDeviceSignaling) { + state.deviceId = deviceId; + } + if (state.state != CallInfo.State.CONNECTED && state.state != CallInfo.State.RECONNECTING) { + state.state = CallInfo.State.CONNECTING; + } fireCallEvent(state, null); logger.debug("Received answer for call {}", callIdUnsigned(callId)); @@ -300,18 +304,45 @@ public class CallManager implements AutoCloseable { logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId)); } - public void handleIncomingHangup(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingHangup(final RecipientId sender, final long callId, final int senderDeviceId, + final HangupMessage.Type type, final int deviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_hangup"); + if (!state.multiDeviceSignaling) { + if (type == null || type == HangupMessage.Type.NORMAL) { + endCall(callId, "remote_hangup"); + } + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedHangup"); + message.put("senderDeviceId", senderDeviceId); + message.put("hangupType", type == null ? 0 : switch (type) { + case NORMAL -> 0; + case ACCEPTED -> 1; + case DECLINED -> 2; + case BUSY -> 3; + case NEED_PERMISSION -> 4; + }); + message.put("deviceId", deviceId); + sendControlMessage(state, writeJson(message)); } - public void handleIncomingBusy(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingBusy(final RecipientId sender, final long callId, final int senderDeviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_busy"); + if (!state.multiDeviceSignaling) { + endCall(callId, "remote_busy"); + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedBusy"); + message.put("senderDeviceId", senderDeviceId); + sendControlMessage(state, writeJson(message)); } // --- Internal helpers --- @@ -483,6 +514,7 @@ public class CallManager implements AutoCloseable { switch (type) { case "ready" -> { + state.multiDeviceSignaling = json.path("signalingVersion").asInt(1) >= 2; if (json.has("inputDeviceName")) { state.inputDeviceName = json.get("inputDeviceName").asText(); } @@ -494,15 +526,18 @@ public class CallManager implements AutoCloseable { state.inputDeviceName, state.outputDeviceName); } + case "signalingCapabilities" -> { + state.multiDeviceSignaling = json.path("version").asInt(1) >= 2; + } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendOfferViaSignal(state, opaque)); + logSendMessageResult(sendOfferViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendAnswer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendAnswerViaSignal(state, opaque)); + logSendMessageResult(sendAnswerViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendIce" -> { var candidatesArr = json.get("candidates"); @@ -510,7 +545,7 @@ public class CallManager implements AutoCloseable { for (var c : candidatesArr) { opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); } - logSendMessageResult(sendIceViaSignal(state, opaqueList)); + logSendMessageResult(sendIceViaSignal(state, opaqueList, receiverDeviceId(state, json))); } case "sendHangup" -> { // RingRTC wants us to send a hangup message via Signal protocol. @@ -518,16 +553,14 @@ public class CallManager implements AutoCloseable { var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; - // Skip multi-device hangup types — signal-cli is single-device, - // and sending these to the remote peer causes it to terminate the call. - if (hangupType.contains("onanotherdevice")) { - logger.debug("Ignoring multi-device hangup type: {}", hangupType); - } else { - logSendMessageResult(sendHangupViaSignal(state, hangupType)); + if (!state.multiDeviceSignaling && hangupType.contains("onanotherdevice")) { + break; } + logSendMessageResult(sendHangupViaSignal(state, hangupType, + json.path("deviceId").asInt(0), receiverDeviceId(state, json))); } case "sendBusy" -> { - logSendMessageResult(sendBusyViaSignal(state)); + logSendMessageResult(sendBusyViaSignal(state, receiverDeviceId(state, json))); } case "stateChange" -> { var ringrtcState = json.get("state").asText(); @@ -616,45 +649,55 @@ public class CallManager implements AutoCloseable { } } - private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) { + private static Integer receiverDeviceId(CallState state, com.fasterxml.jackson.databind.JsonNode event) { + var value = event.get("receiverDeviceId"); + return value == null ? state.deviceId : value.isNull() ? null : value.intValue(); + } + + private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque); - var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) { + private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var answerMessage = new AnswerMessage(state.callId, opaque); - var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendIceViaSignal(CallState state, List opaqueList) { + private SendMessageResult sendIceViaSignal(CallState state, List opaqueList, Integer receiverDeviceId) { var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList(); - var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendBusyViaSignal(CallState state) { + private SendMessageResult sendBusyViaSignal(CallState state, Integer receiverDeviceId) { var busyMessage = new BusyMessage(state.callId); - var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, receiverDeviceId); return context.getSendHelper().sendCallMessage(callMessage, state.recipientId); } - private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) { + private static SignalServiceCallMessage hangupMessage(long callId, String hangupType, int deviceId, Integer receiverDeviceId) { var type = switch (hangupType) { case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED; case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED; case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY; + case "needpermission" -> HangupMessage.Type.NEED_PERMISSION; default -> HangupMessage.Type.NORMAL; }; - var hangupMessage = new HangupMessage(state.callId, type, 0); - var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId); + var hangupMessage = new HangupMessage(callId, type, deviceId); + return SignalServiceCallMessage.forHangup(hangupMessage, receiverDeviceId); + } + + private SendMessageResult sendHangupViaSignal(CallState state, String hangupType, int deviceId, Integer receiverDeviceId) { + var callMessage = hangupMessage(state.callId, hangupType, deviceId, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId)); return result; @@ -700,7 +743,7 @@ public class CallManager implements AutoCloseable { } } - private void endCall(final long callId, final String reason) { + void endCall(final long callId, final String reason) { var state = activeCalls.remove(callId); dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); @@ -775,6 +818,7 @@ public class CallManager implements AutoCloseable { volatile CallInfo.State state; final RecipientId recipientId; volatile Integer deviceId; + volatile boolean multiDeviceSignaling; final boolean isOutgoing; volatile String inputDeviceName; volatile String outputDeviceName; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 7af7b28c..b03b0675 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -463,17 +463,9 @@ public final class IncomingMessageHandler { } }); - callMessage.getHangupMessage().ifPresent(hangup -> { - // Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY - // are multi-device notifications irrelevant for single-device signal-cli. - var hangupType = hangup.getType(); - if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL - || hangupType == null) { - callManager.handleIncomingHangup(hangup.getId()); - } - }); - - callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId())); + callMessage.getHangupMessage().ifPresent(hangup -> + callManager.handleIncomingHangup(sender, hangup.getId(), deviceId, hangup.getType(), hangup.getDeviceId())); + callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(sender, busy.getId(), deviceId)); } private boolean handlePniSignatureMessage( diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java new file mode 100644 index 00000000..3a16f349 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -0,0 +1,176 @@ +package org.asamk.signal.manager.helper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.signal.core.models.ServiceId.ACI; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CallManagerSignalingTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @TempDir + Path directory; + private SignalAccount account; + private RecordingCallManager manager; + private CallManager.CallState state; + private Map calls; + private final StringWriter output = new StringWriter(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + account = SignalAccount.create(directory.toFile(), "account", "+12025550100", + ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"), ServiceEnvironment.STAGING, + KeyUtils.generateIdentityKeyPair(), KeyUtils.generateIdentityKeyPair(), KeyUtils.createProfileKey(), + Settings.DEFAULT); + manager = new RecordingCallManager(new Context(account, null, null, null, null, null)); + var recipient = account.getRecipientStore().resolveRecipient( + ACI.parseOrThrow("22222222-2222-4222-8222-222222222222")); + state = new CallManager.CallState(-1L, CallInfo.State.CONNECTED, recipient, null, true); + state.controlWriter = new PrintWriter(output, true); + var field = CallManager.class.getDeclaredField("activeCalls"); + field.setAccessible(true); + calls = (Map) field.get(manager); + calls.put(state.callId, state); + } + + @AfterEach + void close() throws Exception { + if (calls != null) calls.clear(); + if (manager != null) manager.close(); + if (account != null) account.close(); + } + + private void events(String json) throws Exception { + var method = CallManager.class.getDeclaredMethod("readControlEvents", CallManager.CallState.class, InputStream.class); + method.setAccessible(true); + method.invoke(manager, state, new ByteArrayInputStream((json + "\n").getBytes(StandardCharsets.UTF_8))); + } + + @Test + void readyNegotiatesBeforeCallEvents() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.NORMAL, 0); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedHangup", message.path("type").asText()); + assertEquals(2, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + assertTrue(calls.containsKey(state.callId)); + } + + @Test + void supportsSeparateCapabilityEvent() throws Exception { + events("{\"type\":\"ready\"}\n{\"type\":\"signalingCapabilities\",\"version\":2}"); + manager.handleIncomingBusy(state.recipientId, state.callId, 7); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedBusy", message.path("type").asText()); + assertEquals(7, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void oldTunnelDelegatesNormalHangupToLegacyCleanup() throws Exception { + events("{\"type\":\"ready\"}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.NORMAL, 0); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_hangup", manager.endReason); + assertEquals("", output.toString()); + } + + @Test + void oldTunnelDelegatesBusyToLegacyCleanup() { + manager.handleIncomingBusy(state.recipientId, state.callId, 1); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_busy", manager.endReason); + } + + @Test + void oldTunnelIgnoresOtherDeviceNotification() { + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.ACCEPTED, 1); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void ignoresWrongSenderAndUnknownCall() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + RecipientId stranger = TestRecipientId.createTestId(99999L); + manager.handleIncomingHangup(stranger, state.callId, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(stranger, state.callId, 2); + manager.handleIncomingHangup(state.recipientId, 42, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(state.recipientId, 42, 2); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void preservesBroadcastAndExplicitTargetsWithLegacyFallback() throws Exception { + var method = CallManager.class.getDeclaredMethod("receiverDeviceId", CallManager.CallState.class, JsonNode.class); + method.setAccessible(true); + state.deviceId = 3; + assertEquals(3, method.invoke(null, state, MAPPER.readTree("{}"))); + assertNull(method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":null}"))); + assertEquals(7, method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":7}"))); + } + + @Test + void acceptedNotificationPreservesAnsweredDeviceAndBroadcast() throws Exception { + var method = CallManager.class.getDeclaredMethod("hangupMessage", long.class, String.class, int.class, Integer.class); + method.setAccessible(true); + var message = (SignalServiceCallMessage) method.invoke(null, -1L, "acceptedonanotherdevice", 1, null); + assertTrue(message.getDestinationDeviceId().isEmpty()); + var hangup = message.getHangupMessage().orElseThrow(); + assertEquals(HangupMessage.Type.ACCEPTED, hangup.getType()); + assertEquals(1, hangup.getDeviceId()); + assertEquals(-1L, hangup.getId()); + } + + @ParameterizedTest + @EnumSource(value = CallInfo.State.class, names = {"CONNECTED", "RECONNECTING"}) + void lateAnswerDoesNotReplaceEstablishedState(CallInfo.State established) throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + state.state = established; + manager.handleIncomingAnswer(state.callId, 2, new byte[]{1}); + assertEquals(established, state.state); + assertNull(state.deviceId); + assertEquals(2, MAPPER.readTree(output.toString()).path("senderDeviceId").asInt()); + } + + private static class RecordingCallManager extends CallManager { + long endedCall; + String endReason; + + RecordingCallManager(Context context) { + super(context); + } + + @Override + void endCall(long callId, String reason) { + endedCall = callId; + endReason = reason; + } + } +}