Preserve multi-device call signaling with legacy tunnel compatibility

This commit is contained in:
Robert Nisipeanu 2026-09-22 22:15:50 +03:00
parent 9b6c098137
commit dec7b8207a
No known key found for this signature in database
GPG Key ID: 617A3B9A8F291466
4 changed files with 276 additions and 41 deletions

View File

@ -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:

View File

@ -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<byte[]> opaqueList) {
private SendMessageResult sendIceViaSignal(CallState state, List<byte[]> 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;

View File

@ -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(

View File

@ -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<Long, CallManager.CallState> 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<Long, CallManager.CallState>) 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;
}
}
}