Merge 56597397c4547df5671fbe09a722f1f4482ccfb6 into 3997429779095ff3a2d64c877126e84fce9852aa

This commit is contained in:
Robert Nisipeanu 2026-10-07 13:52:04 +03:00 committed by GitHub
commit 5608ded8e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 429 additions and 44 deletions

View File

@ -31,6 +31,33 @@ 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.
Linked accounts also require the local device-ID correction included in this branch.
Notifications received before capability negotiation are retained until the version
is known. An unversioned `ready` alone does not settle negotiation: a subsequent
capability event enables version 2; a first legacy signaling/state event selects
legacy behavior.
## 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 (!Boolean.TRUE.equals(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,62 @@ 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");
synchronized (state) {
if (activeCalls.get(callId) != state) {
return;
}
if (state.multiDeviceSignaling == null && type != null && type != HangupMessage.Type.NORMAL) {
state.pendingRemoteNotifications.add(() -> handleIncomingHangup(sender, callId, senderDeviceId, type, deviceId));
return;
}
}
if (!Boolean.TRUE.equals(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);
sendRemoteControlMessage(state, 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 (!Boolean.TRUE.equals(state.multiDeviceSignaling)) {
endCall(callId, "remote_busy");
return;
}
var message = mapper.createObjectNode();
message.put("type", "receivedBusy");
message.put("senderDeviceId", senderDeviceId);
sendRemoteControlMessage(state, message);
}
private void sendRemoteControlMessage(CallState state, ObjectNode message) {
synchronized (state) {
if (activeCalls.get(state.callId) == state) {
sendControlMessage(state, writeJson(message));
}
}
}
// --- Internal helpers ---
@ -469,6 +517,17 @@ public class CallManager implements AutoCloseable {
return writeJson(config);
}
private void setSignalingVersion(CallState state, int version) {
synchronized (state) {
state.multiDeviceSignaling = version >= 2;
var pending = List.copyOf(state.pendingRemoteNotifications);
state.pendingRemoteNotifications.clear();
for (var notification : pending) {
notification.run();
}
}
}
private void readControlEvents(CallState state, java.io.InputStream inputStream) {
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
@ -481,8 +540,17 @@ public class CallManager implements AutoCloseable {
var json = mapper.readTree(line);
var type = json.has("type") ? json.get("type").asText() : "";
if (state.multiDeviceSignaling == null && switch (type) {
case "sendOffer", "sendAnswer", "sendIce", "sendHangup", "sendBusy", "stateChange" -> true;
default -> false;
}) {
setSignalingVersion(state, 1);
}
switch (type) {
case "ready" -> {
if (json.has("signalingVersion")) {
setSignalingVersion(state, json.path("signalingVersion").asInt(1));
}
if (json.has("inputDeviceName")) {
state.inputDeviceName = json.get("inputDeviceName").asText();
}
@ -494,15 +562,18 @@ public class CallManager implements AutoCloseable {
state.inputDeviceName,
state.outputDeviceName);
}
case "signalingCapabilities" -> {
setSignalingVersion(state, json.path("version").asInt(1));
}
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 +581,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 +589,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 (!Boolean.TRUE.equals(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 +685,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,13 +779,23 @@ public class CallManager implements AutoCloseable {
}
}
private void endCall(final long callId, final String reason) {
var state = activeCalls.remove(callId);
CallState removeCall(final long callId) {
var state = activeCalls.get(callId);
if (state == null) return null;
synchronized (state) {
if (!activeCalls.remove(callId, state)) return null;
state.pendingRemoteNotifications.clear();
state.state = CallInfo.State.ENDED;
return state;
}
}
void endCall(final long callId, final String reason) {
var state = removeCall(callId);
if (state == null) return;
dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
if (state == null) return;
state.state = CallInfo.State.ENDED;
fireCallEvent(state, reason);
logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason);
@ -775,6 +864,9 @@ public class CallManager implements AutoCloseable {
volatile CallInfo.State state;
final RecipientId recipientId;
volatile Integer deviceId;
// Unknown until capabilities or the first legacy call event arrive.
volatile Boolean multiDeviceSignaling;
final List<Runnable> pendingRemoteNotifications = new ArrayList<>();
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,274 @@
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 java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
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 replaysAcceptedNotificationReceivedBeforeReady() throws Exception {
manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2);
assertEquals("", output.toString());
events("{\"type\":\"ready\",\"signalingVersion\":2}");
var message = MAPPER.readTree(output.toString());
assertEquals("receivedHangup", message.path("type").asText());
assertEquals(1, message.path("hangupType").asInt());
assertEquals(2, message.path("deviceId").asInt());
assertTrue(state.pendingRemoteNotifications.isEmpty());
}
@Test
void separateCapabilitiesDoNotLoseEarlyNotification() throws Exception {
manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.DECLINED, 2);
events("{\"type\":\"ready\"}");
assertEquals("", output.toString());
events("{\"type\":\"signalingCapabilities\",\"version\":2}");
assertEquals(2, MAPPER.readTree(output.toString()).path("hangupType").asInt());
}
@Test
void legacyCallEventResolvesPendingNotification() throws Exception {
manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2);
events("{\"type\":\"ready\"}\n{\"type\":\"stateChange\",\"state\":\"Connected\"}");
assertEquals(Boolean.FALSE, state.multiDeviceSignaling);
assertTrue(state.pendingRemoteNotifications.isEmpty());
assertEquals("", output.toString());
assertEquals(CallInfo.State.CONNECTED, state.state);
}
@Test
void doesNotReplayNotificationsAfterCallRemoval() throws Exception {
manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2);
manager.removeCall(state.callId);
events("{\"type\":\"ready\",\"signalingVersion\":2}");
assertEquals("", output.toString());
assertTrue(state.pendingRemoteNotifications.isEmpty());
}
@Test
void callRemovalWaitsForInFlightNotification() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var registeredAtWrite = new AtomicBoolean();
var failure = new AtomicReference<Throwable>();
state.controlWriter = new PrintWriter(output, true) {
@Override
public void println(String line) {
entered.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("write gate timed out");
registeredAtWrite.set(calls.containsKey(state.callId));
super.println(line);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError(e);
}
}
};
manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2);
var negotiation = new Thread(() -> {
try {
events("{\"type\":\"ready\",\"signalingVersion\":2}");
} catch (Throwable e) {
failure.set(e);
}
});
var cancellation = new Thread(() -> manager.removeCall(state.callId));
negotiation.start();
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
cancellation.start();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (cancellation.getState() != Thread.State.BLOCKED && cancellation.isAlive()
&& System.nanoTime() < deadline) {
Thread.onSpinWait();
}
assertEquals(Thread.State.BLOCKED, cancellation.getState());
assertTrue(calls.containsKey(state.callId));
} finally {
release.countDown();
negotiation.join(5000);
cancellation.join(5000);
}
assertFalse(negotiation.isAlive());
assertFalse(cancellation.isAlive());
assertNull(failure.get());
assertTrue(registeredAtWrite.get());
assertFalse(calls.containsKey(state.callId));
assertEquals(CallInfo.State.ENDED, state.state);
assertTrue(state.pendingRemoteNotifications.isEmpty());
}
@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;
}
}
}