Some call refactoring

This commit is contained in:
AsamK 2026-03-30 22:28:28 +02:00
parent 0a777ea7df
commit 7a8a34f45e
14 changed files with 1054 additions and 457 deletions

View File

@ -61,13 +61,13 @@ The first line written to the tunnel's stdin:
}
```
| Field | Type | Description |
|-------|------|-------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
| Field | Type | Description |
|----------------------|-------------------------|-----------------------------------------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
If `input_device_name` or `output_device_name` are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
@ -84,33 +84,39 @@ lines are control messages.
### signal-cli -> Tunnel (stdin)
| Type | When | Fields |
|------|------|--------|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) |
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
| Type | When | Fields |
|----------------------|----------------------------|---------------------------------------------------------------------------------------------------|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) |
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
### Tunnel -> signal-cli (stdout)
| Type | When | Fields |
|------|------|--------|
| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` |
| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` |
| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` |
| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) |
| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` |
| `sendBusy` | Line is busy | `callId` |
| `stateChange` | Call state transition | `state`, `reason` (optional) |
| `error` | Something went wrong | `message` |
| Type | When | Fields |
|---------------|---------------------------------------------|------------------------------------------------------|
| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` |
| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` |
| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` |
| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) |
| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` |
| `sendBusy` | Line is busy | `callId` |
| `stateChange` | Call state transition | `state`, `reason` (optional) |
| `error` | Something went wrong | `message` |
Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
```json
{"urls":["turn:example.com"],"username":"u","password":"p"}
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
```
---
@ -191,7 +197,6 @@ signal-cli signal-call-tunnel Remote Phone
### JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
It never touches the control socket directly.
**Important:** Call event notifications are not sent by default. Clients must
call `subscribeCallEvents` before initiating or receiving calls. Without this,

View File

@ -4,6 +4,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
@ -37,11 +39,13 @@ import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -64,10 +68,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.TurnServer;
public interface Manager extends Closeable {
static boolean isValidNumber(final String e164Number, final String countryCode) {
@ -425,17 +425,32 @@ public interface Manager extends Closeable {
void hangupCall(long callId) throws IOException;
void rejectCall(long callId) throws IOException;
SendMessageResult rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) throws IOException, UnregisteredRecipientException;
void sendCallOffer(
RecipientIdentifier.Single recipient,
CallOffer offer
) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answerOpaque) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(
RecipientIdentifier.Single recipient,
long callId,
byte[] answerOpaque
) throws IOException, UnregisteredRecipientException;
void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List<byte[]> iceCandidates) throws IOException, UnregisteredRecipientException;
void sendIceUpdate(
RecipientIdentifier.Single recipient,
long callId,
List<byte[]> iceCandidates
) throws IOException, UnregisteredRecipientException;
void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) throws IOException, UnregisteredRecipientException;
void sendHangup(
RecipientIdentifier.Single recipient,
long callId,
MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;

View File

@ -1,30 +1,40 @@
package org.asamk.signal.manager.helper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ -32,6 +42,10 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
/**
* Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel
@ -69,7 +83,7 @@ public class CallManager implements AutoCloseable {
}
private void fireCallEvent(CallState state, String reason) {
var callInfo = state.toCallInfo();
var callInfo = state.toCallInfo(account.getRecipientAddressResolver());
for (var listener : callEventListeners) {
try {
listener.handleCallEvent(callInfo, reason);
@ -80,22 +94,16 @@ public class CallManager implements AutoCloseable {
}
public CallInfo startOutgoingCall(
final RecipientIdentifier.Single recipient
) throws IOException, UnregisteredRecipientException {
final RecipientId recipientId
) throws IOException {
var callId = generateCallId();
var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
var recipientAddress = context.getRecipientHelper()
.resolveSignalServiceAddress(recipientId)
.getServiceId();
var recipientApiAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress();
var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
var state = new CallState(callId,
CallInfo.State.RINGING_OUTGOING,
recipientApiAddress,
recipient,
true);
var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientId, null, true);
logger.debug("Starting outgoing call {} to {} (recipientId: {})",
callIdUnsigned(callId),
recipientAddress,
recipientId);
activeCalls.put(callId, state);
fireCallEvent(state, null);
@ -108,7 +116,7 @@ public class CallManager implements AutoCloseable {
// Send createOutgoingCall + proceed via control channel
var createMsg = mapper.createObjectNode();
createMsg.put("type", "createOutgoingCall");
createMsg.put("callId", callIdUnsigned(callId));
createMsg.put("callId", Utils.callIdUnsigned(callId));
createMsg.put("peerId", recipientAddress.toString());
sendControlMessage(state, writeJson(createMsg));
sendProceed(state, callId, turnServers);
@ -116,17 +124,18 @@ public class CallManager implements AutoCloseable {
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.info("Started outgoing call {} to {}", callId, recipient);
return state.toCallInfo();
logger.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress);
return state.toCallInfo(account.getRecipientAddressResolver());
}
public CallInfo acceptIncomingCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
final var state = getActiveCall(callId);
if (state.state != CallInfo.State.RINGING_INCOMING) {
throw new IOException("Call " + callId + " is not in RINGING_INCOMING state (current: " + state.state + ")");
throw new IOException("Call "
+ callId
+ " is not in RINGING_INCOMING state (current: "
+ state.state
+ ")");
}
// Defer the accept until the tunnel reports Ringing state.
@ -139,46 +148,34 @@ public class CallManager implements AutoCloseable {
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.info("Accepted incoming call {}", callId);
return state.toCallInfo();
logger.debug("Accepted incoming call {}", callIdUnsigned(callId));
return state.toCallInfo(account.getRecipientAddressResolver());
}
public void hangupCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
getActiveCall(callId);
endCall(callId, "local_hangup");
}
public void rejectCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
public SendMessageResult rejectCall(final long callId) throws IOException {
final var callState = getActiveCall(callId);
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy(
busyMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send busy message for call {}", callId, e);
}
final var result = sendBusyMessage(callState.callId, callState.recipientId, callState.deviceId);
endCall(callId, "rejected");
return result;
}
public List<CallInfo> listActiveCalls() {
return activeCalls.values().stream().map(CallState::toCallInfo).toList();
return activeCalls.values()
.stream()
.map((CallState callState) -> callState.toCallInfo(account.getRecipientAddressResolver()))
.toList();
}
public List<TurnServer> getTurnServers() throws IOException {
try {
var result = dependencies.getCallingApi().getTurnServerInfo();
var turnServerList = result.successOrThrow();
var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo());
return turnServerList.stream()
.map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls()))
.toList();
@ -191,47 +188,34 @@ public class CallManager implements AutoCloseable {
// --- Incoming call message handling ---
public void handleIncomingOffer(
final org.asamk.signal.manager.storage.recipients.RecipientId senderId,
final RecipientId recipientId,
final int deviceId,
final long callId,
final MessageEnvelope.Call.Offer.Type type,
final byte[] opaque
) {
if (callEventListeners.isEmpty()) {
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered", callId);
try {
var address = context.getRecipientHelper().resolveSignalServiceAddress(senderId);
var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy(
busyMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send busy for unhandled call {}", callId, e);
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered",
callIdUnsigned(callId));
final var result = sendBusyMessage(callId, recipientId, deviceId);
if (!result.isSuccess()) {
logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId));
}
return;
}
var senderAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(senderId)
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress();
RecipientIdentifier.Single senderIdentifier;
if (senderAddress.number().isPresent()) {
senderIdentifier = new RecipientIdentifier.Number(senderAddress.number().get());
} else if (senderAddress.uuid().isPresent()) {
senderIdentifier = new RecipientIdentifier.Uuid(senderAddress.uuid().get());
} else {
logger.warn("Cannot identify sender for call {}", callId);
return;
}
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
var state = new CallState(callId,
CallInfo.State.RINGING_INCOMING,
var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false);
logger.debug("Starting incoming call {} from {} (recipientId: {})",
callIdUnsigned(callId),
senderAddress,
senderIdentifier,
false);
state.rawOfferOpaque = opaque;
recipientId);
activeCalls.put(callId, state);
// Spawn call tunnel binary immediately
@ -239,7 +223,7 @@ public class CallManager implements AutoCloseable {
// Get identity keys for the receivedOffer message
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Fetch TURN servers
@ -247,16 +231,16 @@ public class CallManager implements AutoCloseable {
try {
turnServers = getTurnServers();
} catch (IOException e) {
logger.warn("Failed to get TURN servers for incoming call {}", callId, e);
logger.warn("Failed to get TURN servers for incoming call {}", callIdUnsigned(callId), e);
turnServers = List.of();
}
// Send receivedOffer to subprocess
var offerMsg = mapper.createObjectNode();
offerMsg.put("type", "receivedOffer");
offerMsg.put("callId", callIdUnsigned(callId));
offerMsg.put("callId", Utils.callIdUnsigned(callId));
offerMsg.put("peerId", senderAddress.toString());
offerMsg.put("senderDeviceId", 1);
offerMsg.put("senderDeviceId", deviceId);
offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
offerMsg.put("age", 0);
offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
@ -271,40 +255,41 @@ public class CallManager implements AutoCloseable {
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.info("Incoming call {} from {}", callId, senderAddress);
logger.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress);
}
public void handleIncomingAnswer(final long callId, final byte[] opaque) {
public void handleIncomingAnswer(final long callId, final int deviceId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.warn("Received answer for unknown call {}", callId);
logger.warn("Received answer for unknown call {}", callIdUnsigned(callId));
return;
}
// Get identity keys
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Forward raw opaque to subprocess
var answerMsg = mapper.createObjectNode();
answerMsg.put("type", "receivedAnswer");
answerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
answerMsg.put("senderDeviceId", 1);
answerMsg.put("senderDeviceId", deviceId);
answerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey));
sendControlMessage(state, writeJson(answerMsg));
state.deviceId = deviceId;
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.info("Received answer for call {}", callId);
logger.debug("Received answer for call {}", callIdUnsigned(callId));
}
public void handleIncomingIceCandidate(final long callId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.debug("Received ICE candidate for unknown call {}", callId);
logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId));
return;
}
@ -314,7 +299,7 @@ public class CallManager implements AutoCloseable {
var candidates = iceMsg.putArray("candidates");
candidates.add(java.util.Base64.getEncoder().encodeToString(opaque));
sendControlMessage(state, writeJson(iceMsg));
logger.debug("Forwarded ICE candidate to tunnel for call {}", callId);
logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId));
}
public void handleIncomingHangup(final long callId) {
@ -333,9 +318,25 @@ public class CallManager implements AutoCloseable {
// --- Internal helpers ---
private CallState getActiveCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callIdUnsigned(callId));
}
return state;
}
private SendMessageResult sendBusyMessage(final long callId, final RecipientId recipientId, final int deviceId) {
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, deviceId);
return context.getSendHelper().sendCallMessage(callMessage, recipientId);
}
private void sendControlMessage(CallState state, String json) {
if (state.controlWriter == null) {
logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json);
logger.debug("Queueing control message for call {} (not yet connected): {}",
callIdUnsigned(state.callId),
json);
state.pendingControlMessages.add(json);
return;
}
@ -345,7 +346,7 @@ public class CallManager implements AutoCloseable {
private void sendProceed(CallState state, long callId, List<TurnServer> turnServers) {
var proceedMsg = mapper.createObjectNode();
proceedMsg.put("type", "proceed");
proceedMsg.put("callId", callIdUnsigned(callId));
proceedMsg.put("callId", Utils.callIdUnsigned(callId));
proceedMsg.put("hideIp", false);
var iceServers = proceedMsg.putArray("iceServers");
for (var ts : turnServers) {
@ -379,8 +380,7 @@ public class CallManager implements AutoCloseable {
stdinStream.flush();
// stdin is the control write channel
state.controlWriter = new PrintWriter(
new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
state.controlWriter = new PrintWriter(new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
// Flush any pending control messages
for (var msg : state.pendingControlMessages) {
@ -392,17 +392,17 @@ public class CallManager implements AutoCloseable {
sendAcceptIfReady(state);
// Read control events from subprocess stdout
Thread.ofVirtual().name("control-read-" + state.callId).start(() -> {
readControlEvents(state, process.getInputStream());
});
Thread.ofVirtual()
.name("control-read-" + callIdUnsigned(state.callId))
.start(() -> readControlEvents(state, process.getInputStream()));
// Drain subprocess stderr to prevent pipe buffer deadlock
Thread.ofVirtual().name("tunnel-stderr-" + state.callId).start(() -> {
try (var reader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
Thread.ofVirtual().name("tunnel-stderr-" + callIdUnsigned(state.callId)).start(() -> {
try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logger.debug("[tunnel-{}] {}", state.callId, line);
logger.debug("[tunnel-{}] {}", callIdUnsigned(state.callId), line);
}
} catch (IOException ignored) {
}
@ -410,15 +410,15 @@ public class CallManager implements AutoCloseable {
// Monitor process exit
process.onExit().thenAcceptAsync(p -> {
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue());
logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue());
if (activeCalls.containsKey(state.callId)) {
endCall(state.callId, "tunnel_exit");
}
});
logger.info("Spawned signal-call-tunnel for call {}", state.callId);
logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId));
} catch (Exception e) {
logger.error("Failed to spawn tunnel for call {}", state.callId, e);
logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(state.callId), e);
endCall(state.callId, "tunnel_spawn_error");
}
}
@ -461,20 +461,19 @@ public class CallManager implements AutoCloseable {
private String buildConfig(CallState state) {
var config = mapper.createObjectNode();
config.put("call_id", callIdUnsigned(state.callId));
config.put("call_id", Utils.callIdUnsigned(state.callId));
config.put("is_outgoing", state.isOutgoing);
config.put("local_device_id", 1);
return writeJson(config);
}
private void readControlEvents(CallState state, java.io.InputStream inputStream) {
try (var reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
logger.debug("Control event for call {}: {}", state.callId, line);
logger.debug("Control event for call {}: {}", callIdUnsigned(state.callId), line);
try {
var json = mapper.readTree(line);
@ -489,17 +488,19 @@ public class CallManager implements AutoCloseable {
state.outputDeviceName = json.get("outputDeviceName").asText();
}
logger.debug("Tunnel ready for call {}: input={}, output={}",
state.callId, state.inputDeviceName, state.outputDeviceName);
callIdUnsigned(state.callId),
state.inputDeviceName,
state.outputDeviceName);
}
case "sendOffer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
sendOfferViaSignal(state, opaque);
logSendMessageResult(sendOfferViaSignal(state, opaque));
}
case "sendAnswer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
sendAnswerViaSignal(state, opaque);
logSendMessageResult(sendAnswerViaSignal(state, opaque));
}
case "sendIce" -> {
var candidatesArr = json.get("candidates");
@ -507,22 +508,24 @@ public class CallManager implements AutoCloseable {
for (var c : candidatesArr) {
opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText()));
}
sendIceViaSignal(state, opaqueList);
logSendMessageResult(sendIceViaSignal(state, opaqueList));
}
case "sendHangup" -> {
// RingRTC wants us to send a hangup message via Signal protocol.
// This is NOT a local state change local state is handled by stateChange events.
var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal";
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 {
sendHangupViaSignal(state, hangupType);
logSendMessageResult(sendHangupViaSignal(state, hangupType));
}
}
case "sendBusy" -> {
sendBusyViaSignal(state);
logSendMessageResult(sendBusyViaSignal(state));
}
case "stateChange" -> {
var ringrtcState = json.get("state").asText();
@ -531,19 +534,23 @@ public class CallManager implements AutoCloseable {
}
case "error" -> {
var message = json.has("message") ? json.get("message").asText("unknown") : "unknown";
logger.error("Tunnel error for call {}: {}", state.callId, message);
logger.error("Tunnel error for call {}: {}", callIdUnsigned(state.callId), message);
endCall(state.callId, "tunnel_error");
}
default -> {
logger.debug("Unknown control event type '{}' for call {}", type, state.callId);
logger.debug("Unknown control event type '{}' for call {}",
type,
callIdUnsigned(state.callId));
}
}
} catch (Exception e) {
logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage());
logger.warn("Failed to parse control event JSON for call {}: {}",
callIdUnsigned(state.callId),
e.getMessage());
}
}
} catch (IOException e) {
logger.debug("Control read ended for call {}: {}", state.callId, e.getMessage());
logger.debug("Control read ended for call {}: {}", callIdUnsigned(state.callId), e.getMessage());
}
}
@ -573,114 +580,97 @@ public class CallManager implements AutoCloseable {
fireCallEvent(state, reason);
}
public static void logSendMessageResult(SendMessageResult result) {
var identifier = result.getAddress().getIdentifier();
if (result.getProofRequiredFailure() != null) {
final var failure = result.getProofRequiredFailure();
logger.warn(
"CAPTCHA proof required for sending to \"{}\", available options \"{}\" with challenge token \"{}\", or wait \"{}\" seconds.\n",
identifier,
failure.getOptions()
.stream()
.map(ProofRequiredException.Option::toString)
.collect(Collectors.joining(", ")),
failure.getToken(),
failure.getRetryAfterSeconds());
} else if (result.isNetworkFailure()) {
logger.warn("Network failure for \"{}\"", identifier);
} else if (result.getRateLimitFailure() != null) {
logger.warn("Rate limit failure for \"{}\"", identifier);
} else if (result.isUnregisteredFailure()) {
logger.warn("Unregistered user \"{}\"", identifier);
} else if (result.getIdentityFailure() != null) {
logger.warn("Untrusted Identity for \"{}\"", identifier);
}
}
private void sendAcceptIfReady(CallState state) {
if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) {
state.acceptPending = false;
logger.debug("Sending deferred accept for call {}", state.callId);
logger.debug("Sending deferred accept for call {}", callIdUnsigned(state.callId));
var acceptMsg = mapper.createObjectNode();
acceptMsg.put("type", "accept");
state.controlWriter.println(writeJson(acceptMsg));
}
}
private void sendOfferViaSignal(CallState state, byte[] opaque) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var offerMessage = new org.whispersystems.signalservice.api.messages.calls.OfferMessage(state.callId,
org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.AUDIO_CALL,
opaque);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forOffer(
offerMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent offer via Signal for call {}", state.callId);
} catch (Exception e) {
logger.warn("Failed to send offer for call {}", state.callId, e);
}
private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) {
var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque);
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private void sendAnswerViaSignal(CallState state, byte[] opaque) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var answerMessage = new org.whispersystems.signalservice.api.messages.calls.AnswerMessage(state.callId, opaque);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forAnswer(
answerMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent answer via Signal for call {}", state.callId);
} catch (Exception e) {
logger.warn("Failed to send answer for call {}", state.callId, e);
}
private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) {
var answerMessage = new AnswerMessage(state.callId, opaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private void sendIceViaSignal(CallState state, List<byte[]> opaqueList) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var iceUpdates = opaqueList.stream()
.map(opaque -> new org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage(
state.callId, opaque))
.toList();
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forIceUpdates(
iceUpdates, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), state.callId);
} catch (Exception e) {
logger.warn("Failed to send ICE for call {}", state.callId, e);
}
private SendMessageResult sendIceViaSignal(CallState state, List<byte[]> opaqueList) {
var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId);
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 void sendBusyViaSignal(CallState state) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(state.callId);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy(
busyMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send busy for call {}", state.callId, e);
}
private SendMessageResult sendBusyViaSignal(CallState state) {
var busyMessage = new BusyMessage(state.callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId);
return context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
}
private void sendHangupViaSignal(CallState state, String hangupType) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var type = switch (hangupType) {
case "accepted", "acceptedonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.ACCEPTED;
case "declined", "declinedonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.DECLINED;
case "busy", "busyonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.BUSY;
default -> org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL;
};
var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(
state.callId, type, 0);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup(
hangupMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent hangup ({}) via Signal for call {}", hangupType, state.callId);
} catch (Exception e) {
logger.warn("Failed to send hangup for call {}", state.callId, e);
}
private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) {
var type = switch (hangupType) {
case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED;
case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED;
case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY;
default -> HangupMessage.Type.NORMAL;
};
var hangupMessage = new HangupMessage(state.callId, type, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId));
return result;
}
private byte[] getRemoteIdentityKey(CallState state) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId);
var serviceId = address.getServiceId();
var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId);
if (identityInfo != null) {
return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize());
return getRawIdentityKeyBytes(identityInfo.getIdentityKey());
}
} catch (Exception e) {
logger.warn("Failed to get remote identity key for call {}", state.callId, e);
logger.warn("Failed to get remote identity key for call {}", callIdUnsigned(state.callId), e);
}
logger.warn("Using local identity key as fallback for remote identity key");
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
}
/**
@ -688,18 +678,18 @@ public class CallManager implements AutoCloseable {
* raw 32-byte Curve25519 public key. Signal Android does this via
* WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC.
*/
private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) {
private static byte[] getRawIdentityKeyBytes(IdentityKey identityKey) {
var serializedKey = identityKey.serialize();
return getRawIdentityKeyBytes(serializedKey);
}
private static byte[] getRawIdentityKeyBytes(final byte[] serializedKey) {
if (serializedKey.length == 33 && serializedKey[0] == 0x05) {
return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length);
}
return serializedKey;
}
/** Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64). */
private static BigInteger callIdUnsigned(long callId) {
return new BigInteger(Long.toUnsignedString(callId));
}
private static String writeJson(ObjectNode node) {
try {
return mapper.writeValueAsString(node);
@ -714,21 +704,19 @@ public class CallManager implements AutoCloseable {
state.state = CallInfo.State.ENDED;
fireCallEvent(state, reason);
logger.info("Call {} ended: {}", callId, reason);
logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason);
// Send Signal protocol hangup to remote peer (unless they initiated the end)
if (!"remote_hangup".equals(reason) && !"rejected".equals(reason) && !"remote_busy".equals(reason)
if (!"remote_hangup".equals(reason)
&& !"rejected".equals(reason)
&& !"remote_busy".equals(reason)
&& !"ringrtc_hangup".equals(reason)) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(callId,
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL, 0);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup(
hangupMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send hangup to remote for call {}", callId, e);
var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
if (!result.isSuccess()) {
logger.warn("Failed to send hangup to remote for call {}", callIdUnsigned(callId));
logSendMessageResult(result);
}
}
@ -755,13 +743,13 @@ public class CallManager implements AutoCloseable {
if (state == null) return;
if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) {
logger.info("Call {} ring timeout", callId);
logger.debug("Call {} ring timeout", callIdUnsigned(callId));
endCall(callId, "ring_timeout");
}
}
private static long generateCallId() {
return new SecureRandom().nextLong() & Long.MAX_VALUE;
return new BigInteger(64, new SecureRandom()).longValue();
}
@Override
@ -770,6 +758,9 @@ public class CallManager implements AutoCloseable {
for (var callId : new ArrayList<>(activeCalls.keySet())) {
endCall(callId, "shutdown");
}
synchronized (callEventListeners) {
callEventListeners.clear();
}
}
// --- Internal call state tracking ---
@ -778,17 +769,15 @@ public class CallManager implements AutoCloseable {
final long callId;
volatile CallInfo.State state;
final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
final RecipientIdentifier.Single recipientIdentifier;
final RecipientId recipientId;
volatile Integer deviceId;
final boolean isOutgoing;
volatile String inputDeviceName;
volatile String outputDeviceName;
volatile Process tunnelProcess;
volatile PrintWriter controlWriter;
// Raw offer opaque for incoming calls (forwarded to subprocess)
volatile byte[] rawOfferOpaque;
// Control messages queued before the tunnel process starts
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
final List<String> pendingControlMessages = Collections.synchronizedList(new ArrayList<>());
// Accept deferred until tunnel reports Ringing state
volatile boolean acceptPending = false;
// True once the tunnel has reported "Ringing" (ready to accept)
@ -797,19 +786,24 @@ public class CallManager implements AutoCloseable {
CallState(
long callId,
CallInfo.State state,
org.asamk.signal.manager.api.RecipientAddress recipientAddress,
RecipientIdentifier.Single recipientIdentifier,
RecipientId recipientId,
final Integer deviceId,
boolean isOutgoing
) {
this.callId = callId;
this.state = state;
this.recipientAddress = recipientAddress;
this.recipientIdentifier = recipientIdentifier;
this.recipientId = recipientId;
this.deviceId = deviceId;
this.isOutgoing = isOutgoing;
}
CallInfo toCallInfo() {
return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing);
CallInfo toCallInfo(RecipientAddressResolver addressResolver) {
return new CallInfo(callId,
state,
addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(),
inputDeviceName,
outputDeviceName,
isOutgoing);
}
}
}

View File

@ -64,6 +64,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType;
@ -402,27 +403,33 @@ public final class IncomingMessageHandler {
}
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender);
handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId);
}
return new Pair<>(actions, longTexts);
}
private void handleCallMessage(
final org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage callMessage,
final org.asamk.signal.manager.storage.recipients.RecipientId sender
final SignalServiceCallMessage callMessage,
final RecipientId sender,
final int deviceId
) {
var callManager = context.getCallManager();
if (callMessage.getDestinationDeviceId().isPresent()
&& callMessage.getDestinationDeviceId().get() != account.getDeviceId()) {
return;
}
callMessage.getOfferMessage().ifPresent(offer -> {
var type = offer.getType() == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL
var type = offer.getType()
== org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL
? org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.VIDEO_CALL
: org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.AUDIO_CALL;
callManager.handleIncomingOffer(sender, offer.getId(), type, offer.getOpaque());
callManager.handleIncomingOffer(sender, deviceId, offer.getId(), type, offer.getOpaque());
});
callMessage.getAnswerMessage().ifPresent(answer ->
callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque()));
callMessage.getAnswerMessage()
.ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque()));
callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> {
for (var ice : iceUpdates) {
@ -440,8 +447,7 @@ public final class IncomingMessageHandler {
}
});
callMessage.getBusyMessage().ifPresent(busy ->
callManager.handleIncomingBusy(busy.getId()));
callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId()));
}
private boolean handlePniSignatureMessage(

View File

@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.DistributionId;
@ -309,6 +310,26 @@ public class SendHelper {
return result;
}
public SendMessageResult sendCallMessage(
final SignalServiceCallMessage callMessage,
final RecipientId recipientId
) {
final var messageSendLogStore = account.getMessageSendLogStore();
final var result = handleSendMessage(recipientId,
(messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendCallMessage(
address,
unidentifiedAccess,
callMessage));
if (callMessage.getTimestamp().isPresent()) {
messageSendLogStore.insertIfPossible(callMessage.getTimestamp().get(),
result,
ContentHint.IMPLICIT,
callMessage.isUrgent());
}
handleSendMessageResult(result);
return result;
}
private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,

View File

@ -21,7 +21,6 @@ import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
@ -65,6 +64,7 @@ import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TextStyle;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -172,7 +172,6 @@ public class ManagerImpl implements Manager {
private boolean isReceivingSynchronous;
private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
private final Set<CallEventListener> callEventListeners = new HashSet<>();
private final List<Runnable> closedListeners = new ArrayList<>();
private final List<Runnable> addressChangedListeners = new ArrayList<>();
private final CompositeDisposable disposable = new CompositeDisposable();
@ -961,11 +960,9 @@ public class ManagerImpl implements Manager {
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
} else if (recipient instanceof RecipientIdentifier.Pni(var pni)) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
} else if (recipient instanceof RecipientIdentifier.Single r) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@ -1717,17 +1714,11 @@ public class ManagerImpl implements Manager {
@Override
public void addCallEventListener(final CallEventListener listener) {
synchronized (callEventListeners) {
callEventListeners.add(listener);
}
context.getCallManager().addCallEventListener(listener);
}
@Override
public void removeCallEventListener(final CallEventListener listener) {
synchronized (callEventListeners) {
callEventListeners.remove(listener);
}
context.getCallManager().removeCallEventListener(listener);
}
@ -1792,7 +1783,8 @@ public class ManagerImpl implements Manager {
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
return context.getCallManager().startOutgoingCall(recipient);
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
return context.getCallManager().startOutgoingCall(recipientId);
}
@Override
@ -1806,8 +1798,9 @@ public class ManagerImpl implements Manager {
}
@Override
public void rejectCall(final long callId) throws IOException {
context.getCallManager().rejectCall(callId);
public SendMessageResult rejectCall(final long callId) throws IOException {
final var result = context.getCallManager().rejectCall(callId);
return toSendMessageResult(result);
}
@Override
@ -1858,9 +1851,7 @@ public class ManagerImpl implements Manager {
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var iceUpdates = iceCandidates.stream()
.map(opaque -> new IceUpdateMessage(callId, opaque))
.toList();
var iceUpdates = iceCandidates.stream().map(opaque -> new IceUpdateMessage(callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
@ -1926,12 +1917,6 @@ public class ManagerImpl implements Manager {
if (thread != null) {
stopReceiveThread(thread);
}
synchronized (callEventListeners) {
for (var listener : callEventListeners) {
context.getCallManager().removeCallEventListener(listener);
}
callEventListeners.clear();
}
context.close();
executor.close();

View File

@ -18,6 +18,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.URI;
@ -235,4 +236,11 @@ public class Utils {
return proxies.getFirst();
}
}
/**
* Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64).
*/
public static BigInteger callIdUnsigned(long callId) {
return new BigInteger(Long.toUnsignedString(callId));
}
}

View File

@ -1,8 +1,8 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.asamk.signal.manager.util.Utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ -29,17 +29,23 @@ class CallManagerTest {
private static final MethodHandle CALL_ID_UNSIGNED;
private static final MethodHandle GENERATE_CALL_ID;
final RecipientAddressResolver recipientAddressResolver = (id) -> new org.asamk.signal.manager.storage.recipients.RecipientAddress(
id.toString());
static {
try {
var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup());
GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, "getRawIdentityKeyBytes",
GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class,
"getRawIdentityKeyBytes",
MethodType.methodType(byte[].class, byte[].class));
CALL_ID_UNSIGNED = lookup.findStatic(CallManager.class, "callIdUnsigned",
CALL_ID_UNSIGNED = lookup.findStatic(Utils.class,
"callIdUnsigned",
MethodType.methodType(BigInteger.class, long.class));
GENERATE_CALL_ID = lookup.findStatic(CallManager.class, "generateCallId",
GENERATE_CALL_ID = lookup.findStatic(CallManager.class,
"generateCallId",
MethodType.methodType(long.class));
} catch (ReflectiveOperationException e) {
@ -62,14 +68,7 @@ class CallManagerTest {
// --- Helper to create a minimal CallState for state machine tests ---
private static CallManager.CallState makeCallState(long callId, CallInfo.State initialState) {
var address = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null);
return new CallManager.CallState(
callId,
initialState,
address,
new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"),
true
);
return new CallManager.CallState(callId, initialState, TestRecipientId.createTestId(15551234567L), null, true);
}
// ========================================================================
@ -165,14 +164,6 @@ class CallManagerTest {
// generateCallId tests
// ========================================================================
@Test
void generateCallId_alwaysNonNegative() throws Throwable {
for (int i = 0; i < 200; i++) {
long id = generateCallId();
assertTrue(id >= 0, "generateCallId returned negative: " + id);
}
}
@Test
void generateCallId_producesVariation() throws Throwable {
long first = generateCallId();
@ -336,11 +327,11 @@ class CallManagerTest {
state.inputDeviceName = "test_input";
state.outputDeviceName = "test_output";
var info = state.toCallInfo();
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(42L, info.callId());
assertEquals(CallInfo.State.CONNECTED, info.state());
assertEquals("+15551234567", info.recipient().number().orElse(null));
assertEquals("RecipientId[id=15551234567]", info.recipient().number().orElse(null));
assertTrue(info.isOutgoing());
assertEquals("test_input", info.inputDeviceName());
assertEquals("test_output", info.outputDeviceName());
@ -350,7 +341,7 @@ class CallManagerTest {
void callState_toCallInfoNullDeviceNames() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
var info = state.toCallInfo();
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(CallInfo.State.RINGING_INCOMING, info.state());
assertEquals(null, info.inputDeviceName());

View File

@ -0,0 +1,8 @@
package org.asamk.signal.manager.storage.recipients;
public class TestRecipientId {
public static RecipientId createTestId(long value) {
return new RecipientId(value, null);
}
}

View File

@ -15,6 +15,8 @@ import org.slf4j.helpers.MessageFormatter;
import java.util.ArrayList;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final Manager m;
@ -297,26 +299,32 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
}
if (callMessage.answer().isPresent()) {
var answerMessage = callMessage.answer().get();
writer.println("Answer message: {}, opaque length: {})", answerMessage.id(), answerMessage.opaque().length);
writer.println("Answer message: {}, opaque length: {})",
callIdUnsigned(answerMessage.id()),
answerMessage.opaque().length);
}
if (callMessage.busy().isPresent()) {
var busyMessage = callMessage.busy().get();
writer.println("Busy message: {}", busyMessage.id());
writer.println("Busy message: {}", callIdUnsigned(busyMessage.id()));
}
if (callMessage.hangup().isPresent()) {
var hangupMessage = callMessage.hangup().get();
writer.println("Hangup message: {}", hangupMessage.id());
writer.println("Hangup message: {}", callIdUnsigned(hangupMessage.id()));
}
if (!callMessage.iceUpdate().isEmpty()) {
writer.println("Ice update messages:");
var iceUpdateMessages = callMessage.iceUpdate();
for (var iceUpdateMessage : iceUpdateMessages) {
writer.println("- {}, opaque length: {}", iceUpdateMessage.id(), iceUpdateMessage.opaque().length);
writer.println("- {}, opaque length: {}",
callIdUnsigned(iceUpdateMessage.id()),
iceUpdateMessage.opaque().length);
}
}
if (callMessage.offer().isPresent()) {
var offerMessage = callMessage.offer().get();
writer.println("Offer message: {}, opaque length: {}", offerMessage.id(), offerMessage.opaque().length);
writer.println("Offer message: {}, opaque length: {}",
callIdUnsigned(offerMessage.id()),
offerMessage.opaque().length);
}
if (callMessage.opaque().isPresent()) {
final var opaqueMessage = callMessage.opaque().get();

View File

@ -4,6 +4,8 @@ import org.asamk.Signal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Contact;
@ -37,12 +39,14 @@ import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -925,12 +929,12 @@ public class DbusManagerImpl implements Manager {
// --- Voice call methods (not supported over DBus) ---
@Override
public org.asamk.signal.manager.api.CallInfo startCall(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient) {
public CallInfo startCall(final RecipientIdentifier.Single recipient) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public org.asamk.signal.manager.api.CallInfo acceptCall(final long callId) {
public CallInfo acceptCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@ -940,42 +944,54 @@ public class DbusManagerImpl implements Manager {
}
@Override
public void rejectCall(final long callId) {
public SendMessageResult rejectCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<org.asamk.signal.manager.api.CallInfo> listActiveCalls() {
public java.util.List<CallInfo> listActiveCalls() {
return java.util.List.of();
}
@Override
public void sendCallOffer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final org.asamk.signal.manager.api.CallOffer offer) {
public void sendCallOffer(final RecipientIdentifier.Single recipient, final CallOffer offer) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendCallAnswer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final byte[] answerOpaque) {
public void sendCallAnswer(
final RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendIceUpdate(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final java.util.List<byte[]> iceCandidates) {
public void sendIceUpdate(
final RecipientIdentifier.Single recipient,
final long callId,
final java.util.List<byte[]> iceCandidates
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendHangup(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final org.asamk.signal.manager.api.MessageEnvelope.Call.Hangup.Type type) {
public void sendHangup(
final RecipientIdentifier.Single recipient,
final long callId,
final MessageEnvelope.Call.Hangup.Type type
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendBusy(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId) {
public void sendBusy(final RecipientIdentifier.Single recipient, final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<org.asamk.signal.manager.api.TurnServer> getTurnServerInfo() {
public java.util.List<TurnServer> getTurnServerInfo() {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}

View File

@ -4,9 +4,12 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.math.BigInteger;
import java.util.Base64;
import java.util.List;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
record JsonCallMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) Answer answerMessage,
@ -23,38 +26,41 @@ record JsonCallMessage(
callMessage.iceUpdate().stream().map(IceUpdate::from).toList());
}
record Offer(long id, String type, String opaque) {
record Offer(BigInteger id, String type, String opaque) {
public static Offer from(final MessageEnvelope.Call.Offer offer) {
return new Offer(offer.id(), offer.type().name(), Base64.getEncoder().encodeToString(offer.opaque()));
return new Offer(callIdUnsigned(offer.id()),
offer.type().name(),
Base64.getEncoder().encodeToString(offer.opaque()));
}
}
public record Answer(long id, String opaque) {
public record Answer(BigInteger id, String opaque) {
public static Answer from(final MessageEnvelope.Call.Answer answer) {
return new Answer(answer.id(), Base64.getEncoder().encodeToString(answer.opaque()));
return new Answer(callIdUnsigned(answer.id()), Base64.getEncoder().encodeToString(answer.opaque()));
}
}
public record Busy(long id) {
public record Busy(BigInteger id) {
public static Busy from(final MessageEnvelope.Call.Busy busy) {
return new Busy(busy.id());
return new Busy(callIdUnsigned(busy.id()));
}
}
public record Hangup(long id, String type, int deviceId) {
public record Hangup(BigInteger id, String type, int deviceId) {
public static Hangup from(final MessageEnvelope.Call.Hangup hangup) {
return new Hangup(hangup.id(), hangup.type().name(), hangup.deviceId());
return new Hangup(callIdUnsigned(hangup.id()), hangup.type().name(), hangup.deviceId());
}
}
public record IceUpdate(long id, String opaque) {
public record IceUpdate(BigInteger id, String opaque) {
public static IceUpdate from(final MessageEnvelope.Call.IceUpdate iceUpdate) {
return new IceUpdate(iceUpdate.id(), Base64.getEncoder().encodeToString(iceUpdate.opaque()));
return new IceUpdate(callIdUnsigned(iceUpdate.id()),
Base64.getEncoder().encodeToString(iceUpdate.opaque()));
}
}
}

View File

@ -1082,6 +1082,12 @@
}
]
},
{
"type": "java.math.BigInteger"
},
{
"type": "java.math.BigInteger[]"
},
{
"type": "java.net.NetPermission"
},
@ -1977,10 +1983,18 @@
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
@ -2064,6 +2078,10 @@
"name": "callId",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
@ -2076,6 +2094,10 @@
"name": "number",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
@ -2297,10 +2319,18 @@
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
@ -2421,6 +2451,43 @@
{
"type": "org.asamk.signal.json.JsonAttachment[]"
},
{
"type": "org.asamk.signal.json.JsonCallEvent",
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
},
{
"name": "number",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "reason",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.json.JsonCallMessage",
"allDeclaredFields": true,
@ -7484,6 +7551,35 @@
"allDeclaredFields": true,
"allDeclaredMethods": true
},
{
"type": "org.whispersystems.signalservice.api.messages.calls.TurnServerInfo",
"fields": [
{
"name": "hostname"
},
{
"name": "password"
},
{
"name": "ttl"
},
{
"name": "urls"
},
{
"name": "urlsWithIps"
},
{
"name": "username"
}
],
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo",
"allDeclaredFields": true,
@ -8064,6 +8160,17 @@
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse",
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.util.List"
]
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.GetUsernameFromLinkResponseBody",
"allDeclaredFields": true,

View File

@ -2,25 +2,57 @@ package org.asamk.signal.jsonrpc;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.api.*;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.output.JsonWriter;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.time.Duration;
import java.util.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands
@ -32,6 +64,7 @@ class SubscribeCallEventsTest {
* Feeds pre-configured JSON-RPC lines to the handler, then returns null to end.
*/
private static class LineFeeder {
private final Queue<String> lines = new ConcurrentLinkedQueue<>();
void addLine(String line) {
@ -47,6 +80,7 @@ class SubscribeCallEventsTest {
* Captures JSON-RPC responses written by the handler.
*/
private static class CapturingJsonWriter implements JsonWriter {
final List<Object> written = Collections.synchronizedList(new ArrayList<>());
@Override
@ -59,6 +93,7 @@ class SubscribeCallEventsTest {
* Minimal Manager stub that tracks call event listener add/remove calls.
*/
private static class StubManager implements Manager {
final List<CallEventListener> listeners = new ArrayList<>();
final AtomicInteger addCount = new AtomicInteger(0);
final AtomicInteger removeCount = new AtomicInteger(0);
@ -68,113 +103,483 @@ class SubscribeCallEventsTest {
this.selfNumber = selfNumber;
}
@Override public void addCallEventListener(CallEventListener listener) {
@Override
public void addCallEventListener(CallEventListener listener) {
addCount.incrementAndGet();
listeners.add(listener);
}
@Override public void removeCallEventListener(CallEventListener listener) {
@Override
public void removeCallEventListener(CallEventListener listener) {
removeCount.incrementAndGet();
listeners.remove(listener);
}
@Override public String getSelfNumber() { return selfNumber; }
@Override
public String getSelfNumber() {
return selfNumber;
}
// --- Stubs for remaining Manager interface methods ---
@Override public Map<String, UserStatus> getUserStatus(Set<String> n) { return Map.of(); }
@Override public Map<String, UsernameStatus> getUsernameStatus(Set<String> u) { return Map.of(); }
@Override public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {}
@Override public Configuration getConfiguration() { return null; }
@Override public void updateConfiguration(Configuration c) {}
@Override public void updateProfile(UpdateProfile u) {}
@Override public String getUsername() { return null; }
@Override public UsernameLinkUrl getUsernameLink() { return null; }
@Override public void setUsername(String u) {}
@Override public void deleteUsername() {}
@Override public void startChangeNumber(String n, boolean v, String c) {}
@Override public void finishChangeNumber(String n, String v, String p) {}
@Override public void unregister() {}
@Override public void deleteAccount() {}
@Override public void submitRateLimitRecaptchaChallenge(String c, String cap) {}
@Override public List<Device> getLinkedDevices() { return List.of(); }
@Override public void updateLinkedDevice(int d, String n) {}
@Override public void removeLinkedDevices(int d) {}
@Override public void addDeviceLink(DeviceLinkUrl u) {}
@Override public void setRegistrationLockPin(Optional<String> p) {}
@Override public List<Group> getGroups() { return List.of(); }
@Override public List<Group> getGroups(Collection<GroupId> g) { return List.of(); }
@Override public SendGroupMessageResults quitGroup(GroupId g, Set<RecipientIdentifier.Single> a) { return null; }
@Override public void deleteGroup(GroupId g) {}
@Override public Pair<GroupId, SendGroupMessageResults> createGroup(String n, Set<RecipientIdentifier.Single> m, String a) { return null; }
@Override public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) { return null; }
@Override public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl u) { return null; }
@Override public SendMessageResults sendTypingMessage(TypingAction a, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List<Long> m) { return null; }
@Override public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List<Long> m) { return null; }
@Override public SendMessageResults sendMessage(Message m, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendEditMessage(Message m, Set<RecipientIdentifier> r, long t) { return null; }
@Override public SendMessageResults sendRemoteDeleteMessage(long t, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendMessageReaction(String e, boolean rm, RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendAdminDelete(RecipientIdentifier.Single a, long t, Set<RecipientIdentifier.Group> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendPinMessage(int d, RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendUnpinMessage(RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) { return null; }
@Override public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> r) { return null; }
@Override public SendMessageResults sendMessageRequestResponse(MessageEnvelope.Sync.MessageRequestResponse.Type t, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendPollCreateMessage(String q, boolean a, List<String> o, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendPollVoteMessage(RecipientIdentifier.Single a, long t, List<Integer> o, int v, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendPollTerminateMessage(long t, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public void hideRecipient(RecipientIdentifier.Single r) {}
@Override public void deleteRecipient(RecipientIdentifier.Single r) {}
@Override public void deleteContact(RecipientIdentifier.Single r) {}
@Override public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {}
@Override public void setContactsBlocked(Collection<RecipientIdentifier.Single> r, boolean b) {}
@Override public void setGroupsBlocked(Collection<GroupId> g, boolean b) {}
@Override public void setExpirationTimer(RecipientIdentifier.Single r, int t) {}
@Override public StickerPackUrl uploadStickerPack(File p) { return null; }
@Override public void installStickerPack(StickerPackUrl u) {}
@Override public List<StickerPack> getStickerPacks() { return List.of(); }
@Override public void requestAllSyncData() {}
@Override public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {}
@Override public void removeReceiveHandler(ReceiveMessageHandler h) {}
@Override public boolean isReceiving() { return false; }
@Override public void receiveMessages(Optional<Duration> t, Optional<Integer> m, ReceiveMessageHandler h) {}
@Override public void stopReceiveMessages() {}
@Override public void setReceiveConfig(ReceiveConfig r) {}
@Override public boolean isContactBlocked(RecipientIdentifier.Single r) { return false; }
@Override public void sendContacts() {}
@Override public List<Recipient> getRecipients(boolean o, Optional<Boolean> b, Collection<RecipientIdentifier.Single> a, Optional<String> n) { return List.of(); }
@Override public String getContactOrProfileName(RecipientIdentifier.Single r) { return null; }
@Override public Group getGroup(GroupId g) { return null; }
@Override public List<Identity> getIdentities() { return List.of(); }
@Override public List<Identity> getIdentities(RecipientIdentifier.Single r) { return List.of(); }
@Override public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) { return false; }
@Override public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) { return false; }
@Override public void addAddressChangedListener(Runnable l) {}
@Override public void addClosedListener(Runnable l) {}
@Override public InputStream retrieveAttachment(String id) { return null; }
@Override public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) { return null; }
@Override public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) { return null; }
@Override public InputStream retrieveGroupAvatar(GroupId g) { return null; }
@Override public InputStream retrieveSticker(StickerPackId s, int i) { return null; }
@Override public CallInfo startCall(RecipientIdentifier.Single r) { return null; }
@Override public CallInfo acceptCall(long c) { return null; }
@Override public void hangupCall(long c) {}
@Override public void rejectCall(long c) {}
@Override public List<CallInfo> listActiveCalls() { return List.of(); }
@Override public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {}
@Override public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {}
@Override public void sendIceUpdate(RecipientIdentifier.Single r, long c, List<byte[]> i) {}
@Override public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {}
@Override public void sendBusy(RecipientIdentifier.Single r, long c) {}
@Override public List<TurnServer> getTurnServerInfo() { return List.of(); }
@Override public void close() {}
@Override
public Map<String, UserStatus> getUserStatus(Set<String> n) {
return Map.of();
}
@Override
public Map<String, UsernameStatus> getUsernameStatus(Set<String> u) {
return Map.of();
}
@Override
public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {
}
@Override
public Configuration getConfiguration() {
return null;
}
@Override
public void updateConfiguration(Configuration c) {
}
@Override
public void updateProfile(UpdateProfile u) {
}
@Override
public String getUsername() {
return null;
}
@Override
public UsernameLinkUrl getUsernameLink() {
return null;
}
@Override
public void setUsername(String u) {
}
@Override
public void deleteUsername() {
}
@Override
public void startChangeNumber(String n, boolean v, String c) {
}
@Override
public void finishChangeNumber(String n, String v, String p) {
}
@Override
public void unregister() {
}
@Override
public void deleteAccount() {
}
@Override
public void submitRateLimitRecaptchaChallenge(String c, String cap) {
}
@Override
public List<Device> getLinkedDevices() {
return List.of();
}
@Override
public void updateLinkedDevice(int d, String n) {
}
@Override
public void removeLinkedDevices(int d) {
}
@Override
public void addDeviceLink(DeviceLinkUrl u) {
}
@Override
public void setRegistrationLockPin(Optional<String> p) {
}
@Override
public List<Group> getGroups() {
return List.of();
}
@Override
public List<Group> getGroups(Collection<GroupId> g) {
return List.of();
}
@Override
public SendGroupMessageResults quitGroup(GroupId g, Set<RecipientIdentifier.Single> a) {
return null;
}
@Override
public void deleteGroup(GroupId g) {
}
@Override
public Pair<GroupId, SendGroupMessageResults> createGroup(
String n,
Set<RecipientIdentifier.Single> m,
String a
) {
return null;
}
@Override
public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) {
return null;
}
@Override
public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl u) {
return null;
}
@Override
public SendMessageResults sendTypingMessage(TypingAction a, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendMessage(Message m, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public SendMessageResults sendEditMessage(Message m, Set<RecipientIdentifier> r, long t) {
return null;
}
@Override
public SendMessageResults sendRemoteDeleteMessage(long t, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendMessageReaction(
String e,
boolean rm,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendAdminDelete(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier.Group> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPinMessage(
int d,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) {
return null;
}
@Override
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> r) {
return null;
}
@Override
public SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type t,
Set<RecipientIdentifier> r
) {
return null;
}
@Override
public SendMessageResults sendPollCreateMessage(
String q,
boolean a,
List<String> o,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollVoteMessage(
RecipientIdentifier.Single a,
long t,
List<Integer> o,
int v,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollTerminateMessage(long t, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public void hideRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteContact(RecipientIdentifier.Single r) {
}
@Override
public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {
}
@Override
public void setContactsBlocked(Collection<RecipientIdentifier.Single> r, boolean b) {
}
@Override
public void setGroupsBlocked(Collection<GroupId> g, boolean b) {
}
@Override
public void setExpirationTimer(RecipientIdentifier.Single r, int t) {
}
@Override
public StickerPackUrl uploadStickerPack(File p) {
return null;
}
@Override
public void installStickerPack(StickerPackUrl u) {
}
@Override
public List<StickerPack> getStickerPacks() {
return List.of();
}
@Override
public void requestAllSyncData() {
}
@Override
public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {
}
@Override
public void removeReceiveHandler(ReceiveMessageHandler h) {
}
@Override
public boolean isReceiving() {
return false;
}
@Override
public void receiveMessages(Optional<Duration> t, Optional<Integer> m, ReceiveMessageHandler h) {
}
@Override
public void stopReceiveMessages() {
}
@Override
public void setReceiveConfig(ReceiveConfig r) {
}
@Override
public boolean isContactBlocked(RecipientIdentifier.Single r) {
return false;
}
@Override
public void sendContacts() {
}
@Override
public List<Recipient> getRecipients(
boolean o,
Optional<Boolean> b,
Collection<RecipientIdentifier.Single> a,
Optional<String> n
) {
return List.of();
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single r) {
return null;
}
@Override
public Group getGroup(GroupId g) {
return null;
}
@Override
public List<Identity> getIdentities() {
return List.of();
}
@Override
public List<Identity> getIdentities(RecipientIdentifier.Single r) {
return List.of();
}
@Override
public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) {
return false;
}
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) {
return false;
}
@Override
public void addAddressChangedListener(Runnable l) {
}
@Override
public void addClosedListener(Runnable l) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;
}
@Override
public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveGroupAvatar(GroupId g) {
return null;
}
@Override
public InputStream retrieveSticker(StickerPackId s, int i) {
return null;
}
@Override
public CallInfo startCall(RecipientIdentifier.Single r) {
return null;
}
@Override
public CallInfo acceptCall(long c) {
return null;
}
@Override
public void hangupCall(long c) {
}
@Override
public SendMessageResult rejectCall(long c) {
return null;
}
@Override
public List<CallInfo> listActiveCalls() {
return List.of();
}
@Override
public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {
}
@Override
public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {
}
@Override
public void sendIceUpdate(RecipientIdentifier.Single r, long c, List<byte[]> i) {
}
@Override
public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {
}
@Override
public void sendBusy(RecipientIdentifier.Single r, long c) {
}
@Override
public List<TurnServer> getTurnServerInfo() {
return List.of();
}
@Override
public void close() {
}
}
/**
* Minimal MultiAccountManager stub for multi-account mode tests.
*/
private static class StubMultiAccountManager implements MultiAccountManager {
final List<Manager> managers;
final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
@ -182,26 +587,48 @@ class SubscribeCallEventsTest {
this.managers = new ArrayList<>(managers);
}
@Override public List<String> getAccountNumbers() {
@Override
public List<String> getAccountNumbers() {
return managers.stream().map(Manager::getSelfNumber).toList();
}
@Override public List<Manager> getManagers() { return managers; }
@Override
public List<Manager> getManagers() {
return managers;
}
@Override public void addOnManagerAddedHandler(Consumer<Manager> handler) {
@Override
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
addedHandlers.add(handler);
}
@Override public void addOnManagerRemovedHandler(Consumer<Manager> handler) {}
@Override
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
}
@Override public Manager getManager(String phoneNumber) {
@Override
public Manager getManager(String phoneNumber) {
return managers.stream().filter(m -> phoneNumber.equals(m.getSelfNumber())).findFirst().orElse(null);
}
@Override public URI getNewProvisioningDeviceLinkUri() { return null; }
@Override public ProvisioningManager getProvisioningManagerFor(URI u) { return null; }
@Override public RegistrationManager getNewRegistrationManager(String a) { return null; }
@Override public void close() {}
@Override
public URI getNewProvisioningDeviceLinkUri() {
return null;
}
@Override
public ProvisioningManager getProvisioningManagerFor(URI u) {
return null;
}
@Override
public RegistrationManager getNewRegistrationManager(String a) {
return null;
}
@Override
public void close() {
}
}
private static String jsonRpcCall(int id, String method) {