mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-10-30 09:30:20 +00:00
Merge 56597397c4547df5671fbe09a722f1f4482ccfb6 into 3997429779095ff3a2d64c877126e84fce9852aa
This commit is contained in:
commit
5608ded8e1
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user