Merge branch 'master' into openapi-docs

This commit is contained in:
Era Dorta 2026-04-02 23:28:43 +02:00
commit 52543fbc5e
35 changed files with 3833 additions and 65 deletions

View File

@ -5,7 +5,7 @@ plugins {
application
eclipse
`check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.5"
id("org.graalvm.buildtools.native") version "1.0.0"
}
allprojects {
@ -104,6 +104,14 @@ dependencies {
implementation(libs.micronaut.json.schema.generator)
}
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
configurations {

359
docs/CALL_TUNNEL.md Normal file
View File

@ -0,0 +1,359 @@
# Voice Call Support
## Overview
signal-cli supports voice calls by spawning a subprocess called
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and
audio transport. signal-cli communicates with the tunnel over its stdin/stdout
using newline-delimited JSON messages, relaying signaling between the tunnel
and the Signal protocol.
```
signal-cli signal-call-tunnel
| |
|-- spawn --------------------------->|
|-- config JSON on stdin ------------>|
| |
|-- commands on stdin --------------->|
|<-- events on stdout ----------------|
| | WebRTC
| signaling relay | audio I/O
| |
| (stderr: tunnel logging) -------->| (captured by signal-cli)
```
Each call gets its own tunnel process. When the call ends, signal-cli closes
stdin and destroys the process.
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
returned by the tunnel in its `ready` message. signal-cli passes them through
to JSON-RPC clients, which use them to connect audio via platform APIs.
---
## Spawning the Tunnel
For each call, signal-cli:
1. Spawns `signal-call-tunnel`
2. Writes config JSON followed by a newline to stdin
3. Keeps stdin open for subsequent control messages
4. Reads control events from stdout
5. Captures stderr for logging
The `signal-call-tunnel` binary is located by searching (in order):
1. `SIGNAL_CALL_TUNNEL_BIN` environment variable
2. `<signal-cli install dir>/bin/signal-call-tunnel` (detected from jar location)
3. `signal-call-tunnel` on `PATH`
### Config JSON
The first line written to the tunnel's stdin:
```json
{
"call_id": 12345,
"is_outgoing": true,
"local_device_id": 1,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}
```
| 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.,
`signal_input_<call_id>`). On macOS, these are the fixed names `signal_input`
and `signal_output`, which must match the pre-installed BlackHole drivers.
---
## Control Protocol
Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout
(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent
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)* |
### 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` |
Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
```json
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
```
---
## Startup Sequence
```
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
|-- config JSON + newline on stdin ---->|
| | parse config
| | initialize audio
| |
|<-------- ready (on stdout) -----------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
| |
|-- control messages on stdin --------->|
|<-- control events on stdout ----------|
```
---
## Call Flows
### Outgoing call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| |
| | create offer |
|<-- sendOffer ------------| |
|-- offer via Signal -------------------------------->|
|<-- answer via Signal -------------------------------|
|-- receivedAnswer ------->| (+ identity keys) |
|<-- sendIce --------------| |
|-- ICE via Signal -------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connects |
|<-- stateChange:Connected | |
```
### Incoming call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|<-- offer via Signal --------------------------------|
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| |
| | process offer |
|<-- sendAnswer -----------| |
|-- answer via Signal -------------------------------->|
|<-- sendIce --------------| |
|-- ICE via Signal ------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connecting... |
| | |
| (user accepts call) | |
| Java defers accept | |
| | |
|<-- stateChange:Ringing --| (tunnel ready to accept)|
|-- accept --------------->| (deferred accept sent) |
| | accept |
|<-- stateChange:Connected | |
```
### JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
**Important:** Call event notifications are not sent by default. Clients must
call `subscribeCallEvents` before initiating or receiving calls. Without this,
incoming calls are silently ignored (no tunnel is spawned).
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (required: enables call support)
| |
|-- startCall(recipient) ------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: RINGING_OUTGOING ------|
| ... remote answers ... |
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
| |
|-- hangupCall(callId) --------------->| (or: receive callEvent ENDED)
|<-- callEvent: ENDED -----------------|
| disconnect from audio devices |
```
For incoming calls:
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (if not already subscribed)
| |
|<-- callEvent: RINGING_INCOMING ------| (includes callId, device names)
| |
|-- acceptCall(callId) --------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: CONNECTING ------------|
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
```
To stop receiving call events, call `unsubscribeCallEvents`.
---
## State Machine
Call states as seen by JSON-RPC clients:
```
startCall()
|
v
+----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+
| | | | |
| (timeout | (answered) | (rejected) | acceptCall() | (timeout
| ~60s) | | | | ~60s)
v v v v v
ENDED CONNECTED ENDED CONNECTING ENDED
| |
| v
| CONNECTED
| |
| (hangup/error) | (hangup/error)
v v
ENDED ENDED
```
For outgoing calls, `CONNECTED` fires directly when the tunnel reports
`Connected` state -- there is no intermediate `CONNECTING` event.
For incoming calls, `CONNECTING` is set by Java when the user calls
`acceptCall()`, before the tunnel completes ICE negotiation.
Both directions have a 60-second ring timeout.
Reconnection (ICE restart):
```
CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded)
|
v
ENDED (ICE restart failed)
```
`RECONNECTING` maps from the tunnel's `Connecting` state, which is emitted
during ICE restarts (not during initial connection).
---
## CallManager.java
`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java`
Manages the call lifecycle from the Java side:
1. Spawns `signal-call-tunnel` and writes config JSON to stdin
2. Keeps stdin open as the control write channel; reads stdout for control events
3. Captures stderr for tunnel logging
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
message and includes them in `CallInfo`
5. Translates tunnel state changes into `CallInfo.State` values and fires
`callEvent` JSON-RPC notifications to connected clients
6. Defers the `accept` message for incoming calls until the tunnel reports
`Ringing` state (sending earlier causes the tunnel to drop it)
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
8. On hangup: sends hangup message, closes stdin, and destroys the process
---
## Implementation Notes
### Peer ID consistency
The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual
remote peer UUID (e.g., `senderAddress.toString()`). The tunnel rejects ICE
candidates if the peer ID doesn't match across calls, causing "Ignoring
peer-reflexive ICE candidate because the ufrag is unknown."
### sendHangup semantics
`sendHangup` from the tunnel is a request to send a hangup message via Signal
protocol. It is **not** a local state change -- local state transitions come
exclusively from `stateChange` events. For single-device clients, ignore
`AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and
`BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to
the remote peer causes it to terminate the call prematurely.
### Call ID serialization
Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when
serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In
the config JSON, `call_id` should also use unsigned representation.
### Incoming hangup filtering
When receiving hangup messages via Signal protocol, only honor `NORMAL` type
hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination
messages and should be ignored by single-device clients.
### JSON-RPC call ID types
JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use `Number.longValue()` rather than direct casting when extracting
call IDs from JSON-RPC parameters.
### Identity key format
Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw
32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). If the
33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures.

View File

@ -18,7 +18,7 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.32"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_141"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_142"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }

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;
@ -413,9 +417,52 @@ public interface Manager extends Closeable {
InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException;
// --- Voice call methods ---
CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
CallInfo acceptCall(long callId) throws IOException;
void hangupCall(long callId) throws IOException;
SendMessageResult rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(
RecipientIdentifier.Single recipient,
CallOffer offer
) 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 sendHangup(
RecipientIdentifier.Single recipient,
long callId,
MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;
List<TurnServer> getTurnServerInfo() throws IOException;
@Override
void close();
void addCallEventListener(CallEventListener listener);
void removeCallEventListener(CallEventListener listener);
interface ReceiveMessageHandler {
ReceiveMessageHandler EMPTY = (envelope, e) -> {
@ -423,4 +470,9 @@ public interface Manager extends Closeable {
void handleMessage(MessageEnvelope envelope, Throwable e);
}
interface CallEventListener {
void handleCallEvent(CallInfo callInfo, String reason);
}
}

View File

@ -60,7 +60,7 @@ public class SendRetryMessageRequestAction implements HandleAction {
return CiphertextMessage.WHISPER_TYPE;
}
return switch (type) {
case PREKEY_BUNDLE -> CiphertextMessage.PREKEY_TYPE;
case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE;
case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE;
case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE;
default -> CiphertextMessage.WHISPER_TYPE;

View File

@ -0,0 +1,21 @@
package org.asamk.signal.manager.api;
public record CallInfo(
long callId,
State state,
RecipientAddress recipient,
String inputDeviceName,
String outputDeviceName,
boolean isOutgoing
) {
public enum State {
IDLE,
RINGING_INCOMING,
RINGING_OUTGOING,
CONNECTING,
CONNECTED,
RECONNECTING,
ENDED
}
}

View File

@ -0,0 +1,13 @@
package org.asamk.signal.manager.api;
public record CallOffer(
long callId,
Type type,
byte[] opaque
) {
public enum Type {
AUDIO,
VIDEO
}
}

View File

@ -0,0 +1,10 @@
package org.asamk.signal.manager.api;
import java.util.List;
public record TurnServer(
String username,
String password,
List<String> urls
) {
}

View File

@ -0,0 +1,810 @@
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.TurnServer;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
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.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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
* subprocess, routes incoming call messages, and handles timeouts.
*/
public class CallManager implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(CallManager.class);
private static final long RING_TIMEOUT_MS = 60_000;
private static final ObjectMapper mapper = new ObjectMapper();
private final Context context;
private final SignalAccount account;
private final SignalDependencies dependencies;
private final Map<Long, CallState> activeCalls = new ConcurrentHashMap<>();
private final List<Manager.CallEventListener> callEventListeners = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
var t = new Thread(r, "call-timeout-scheduler");
t.setDaemon(true);
return t;
});
public CallManager(final Context context) {
this.context = context;
this.account = context.getAccount();
this.dependencies = context.getDependencies();
}
public void addCallEventListener(Manager.CallEventListener listener) {
callEventListeners.add(listener);
}
public void removeCallEventListener(Manager.CallEventListener listener) {
callEventListeners.remove(listener);
}
private void fireCallEvent(CallState state, String reason) {
var callInfo = state.toCallInfo(account.getRecipientAddressResolver());
for (var listener : callEventListeners) {
try {
listener.handleCallEvent(callInfo, reason);
} catch (Throwable e) {
logger.warn("Call event listener failed, ignoring", e);
}
}
}
public CallInfo startOutgoingCall(
final RecipientId recipientId
) throws IOException {
var callId = generateCallId();
var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
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);
// Spawn call tunnel binary and connect control channel
spawnMediaTunnel(state);
// Fetch TURN servers
var turnServers = getTurnServers();
// Send createOutgoingCall + proceed via control channel
var createMsg = mapper.createObjectNode();
createMsg.put("type", "createOutgoingCall");
createMsg.put("callId", Utils.callIdUnsigned(callId));
createMsg.put("peerId", recipientAddress.toString());
sendControlMessage(state, writeJson(createMsg));
sendProceed(state, callId, turnServers);
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress);
return state.toCallInfo(account.getRecipientAddressResolver());
}
public CallInfo acceptIncomingCall(final long callId) throws IOException {
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
+ ")");
}
// Defer the accept until the tunnel reports Ringing state.
// Sending accept too early (while RingRTC is in ConnectingBeforeAccepted)
// causes it to be silently dropped.
state.acceptPending = true;
// If the tunnel is already in Ringing state, send immediately
sendAcceptIfReady(state);
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.debug("Accepted incoming call {}", callIdUnsigned(callId));
return state.toCallInfo(account.getRecipientAddressResolver());
}
public void hangupCall(final long callId) throws IOException {
getActiveCall(callId);
endCall(callId, "local_hangup");
}
public SendMessageResult rejectCall(final long callId) throws IOException {
final var callState = getActiveCall(callId);
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 callState) -> callState.toCallInfo(account.getRecipientAddressResolver()))
.toList();
}
public List<TurnServer> getTurnServers() throws IOException {
try {
var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo());
return turnServerList.stream()
.map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls()))
.toList();
} catch (Throwable e) {
logger.warn("Failed to get TURN server info, returning empty list", e);
return List.of();
}
}
// --- Incoming call message handling ---
public void handleIncomingOffer(
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",
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(recipientId)
.toApiRecipientAddress();
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false);
logger.debug("Starting incoming call {} from {} (recipientId: {})",
callIdUnsigned(callId),
senderAddress,
recipientId);
activeCalls.put(callId, state);
// Spawn call tunnel binary immediately
spawnMediaTunnel(state);
// 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());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Fetch TURN servers
List<TurnServer> turnServers;
try {
turnServers = getTurnServers();
} catch (IOException 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", Utils.callIdUnsigned(callId));
offerMsg.put("peerId", senderAddress.toString());
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));
offerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey));
sendControlMessage(state, writeJson(offerMsg));
// Send proceed with TURN servers
sendProceed(state, callId, turnServers);
fireCallEvent(state, null);
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress);
}
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 {}", 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());
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", 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.debug("Received answer for call {}", callIdUnsigned(callId));
}
public void handleIncomingIceCandidate(final long callId, final byte[] opaque, final int deviceId) {
var state = activeCalls.get(callId);
if (state == null) {
logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId));
return;
}
// Forward to subprocess as receivedIce
var iceMsg = mapper.createObjectNode();
iceMsg.put("type", "receivedIce");
iceMsg.put("senderDeviceId", deviceId);
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 {}", callIdUnsigned(callId));
}
public void handleIncomingHangup(final long callId) {
if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) {
return;
}
endCall(callId, "remote_hangup");
}
public void handleIncomingBusy(final long callId) {
if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) {
return;
}
endCall(callId, "remote_busy");
}
// --- 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): {}",
callIdUnsigned(state.callId),
json);
state.pendingControlMessages.add(json);
return;
}
state.controlWriter.println(json);
}
private void sendProceed(CallState state, long callId, List<TurnServer> turnServers) {
var proceedMsg = mapper.createObjectNode();
proceedMsg.put("type", "proceed");
proceedMsg.put("callId", Utils.callIdUnsigned(callId));
proceedMsg.put("hideIp", false);
var iceServers = proceedMsg.putArray("iceServers");
for (var ts : turnServers) {
var server = iceServers.addObject();
server.put("username", ts.username());
server.put("password", ts.password());
var urls = server.putArray("urls");
for (var url : ts.urls()) {
urls.add(url);
}
}
sendControlMessage(state, writeJson(proceedMsg));
}
private void spawnMediaTunnel(CallState state) {
try {
var command = new ArrayList<>(List.of(findTunnelBinary()));
var processBuilder = new ProcessBuilder(command);
// Keep stdout and stderr separate: stdout = control protocol, stderr = logging
processBuilder.redirectErrorStream(false);
var process = processBuilder.start();
state.tunnelProcess = process;
// Write config JSON to stdin, then keep stdin open for control messages
var config = buildConfig(state);
var stdinStream = process.getOutputStream();
stdinStream.write(config.getBytes(StandardCharsets.UTF_8));
stdinStream.write('\n');
stdinStream.flush();
// stdin is the control write channel
state.controlWriter = new PrintWriter(new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
// Flush any pending control messages
for (var msg : state.pendingControlMessages) {
state.controlWriter.println(msg);
}
state.pendingControlMessages.clear();
// If accept was deferred, send it now
sendAcceptIfReady(state);
// Read control events from subprocess stdout
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-" + 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-{}] {}", callIdUnsigned(state.callId), line);
}
} catch (IOException ignored) {
}
});
// Monitor process exit
process.onExit().thenAcceptAsync(p -> {
logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue());
if (activeCalls.containsKey(state.callId)) {
endCall(state.callId, "tunnel_exit");
}
});
logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId));
} catch (Exception e) {
logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(state.callId), e);
endCall(state.callId, "tunnel_spawn_error");
}
}
private String findTunnelBinary() {
// Check environment variable first
var envPath = System.getenv("SIGNAL_CALL_TUNNEL_BIN");
if (envPath != null && !envPath.isEmpty()) {
return envPath;
}
// Check relative to the signal-cli installation directory
try {
var codeSource = CallManager.class.getProtectionDomain().getCodeSource();
if (codeSource != null) {
var jarPath = Path.of(codeSource.getLocation().toURI());
var binPath = tunnelBinaryFromCodeSourcePath(jarPath);
if (Files.isExecutable(binPath)) {
return binPath.toString();
}
}
} catch (Exception e) {
logger.debug("Failed to determine install dir from code source", e);
}
// Fall back to PATH
return "signal-call-tunnel";
}
/**
* Resolves the expected tunnel binary path from a code source path.
* The code source (jar or class dir) is expected to be in {@code <install>/lib/},
* so we go up two levels to reach the install root, then look for
* {@code bin/signal-call-tunnel}.
*/
static Path tunnelBinaryFromCodeSourcePath(Path codeSourcePath) {
var installDir = codeSourcePath.getParent().getParent();
return installDir.resolve("bin").resolve("signal-call-tunnel");
}
private String buildConfig(CallState state) {
var config = mapper.createObjectNode();
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))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
logger.debug("Control event for call {}: {}", callIdUnsigned(state.callId), line);
try {
var json = mapper.readTree(line);
var type = json.has("type") ? json.get("type").asText() : "";
switch (type) {
case "ready" -> {
if (json.has("inputDeviceName")) {
state.inputDeviceName = json.get("inputDeviceName").asText();
}
if (json.has("outputDeviceName")) {
state.outputDeviceName = json.get("outputDeviceName").asText();
}
logger.debug("Tunnel ready for call {}: input={}, output={}",
callIdUnsigned(state.callId),
state.inputDeviceName,
state.outputDeviceName);
}
case "sendOffer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
logSendMessageResult(sendOfferViaSignal(state, opaque));
}
case "sendAnswer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
logSendMessageResult(sendAnswerViaSignal(state, opaque));
}
case "sendIce" -> {
var candidatesArr = json.get("candidates");
var opaqueList = new ArrayList<byte[]>();
for (var c : candidatesArr) {
opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText()));
}
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";
// 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));
}
}
case "sendBusy" -> {
logSendMessageResult(sendBusyViaSignal(state));
}
case "stateChange" -> {
var ringrtcState = json.get("state").asText();
var reason = json.has("reason") ? json.get("reason").asText(null) : null;
handleStateChange(state, ringrtcState, reason);
}
case "error" -> {
var message = json.has("message") ? json.get("message").asText("unknown") : "unknown";
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,
callIdUnsigned(state.callId));
}
}
} catch (Exception e) {
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 {}: {}", callIdUnsigned(state.callId), e.getMessage());
}
}
private void handleStateChange(CallState state, String ringrtcState, String reason) {
if (ringrtcState.startsWith("Incoming")) {
// Don't downgrade if we've already accepted
if (state.state == CallInfo.State.CONNECTING) return;
state.state = CallInfo.State.RINGING_INCOMING;
} else if (ringrtcState.startsWith("Outgoing")) {
state.state = CallInfo.State.RINGING_OUTGOING;
} else if ("Ringing".equals(ringrtcState)) {
// Tunnel is now ready to accept flush deferred accept if pending
state.tunnelRinging = true;
sendAcceptIfReady(state);
return;
} else if ("Connected".equals(ringrtcState)) {
state.state = CallInfo.State.CONNECTED;
} else if ("Connecting".equals(ringrtcState)) {
state.state = CallInfo.State.RECONNECTING;
} else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) {
endCall(state.callId, reason != null ? reason : ringrtcState.toLowerCase());
return;
} else if ("Concluded".equals(ringrtcState)) {
// Cleanup, no-op
return;
}
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 {}", callIdUnsigned(state.callId));
var acceptMsg = mapper.createObjectNode();
acceptMsg.put("type", "accept");
state.controlWriter.println(writeJson(acceptMsg));
}
}
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 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 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 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 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 address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId);
var serviceId = address.getServiceId();
var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId);
if (identityInfo != null) {
return getRawIdentityKeyBytes(identityInfo.getIdentityKey());
}
} catch (Exception 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());
}
/**
* Strip the 0x05 DJB type prefix from a serialized identity key to get the
* raw 32-byte Curve25519 public key. Signal Android does this via
* WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC.
*/
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;
}
private static String writeJson(ObjectNode node) {
try {
return mapper.writeValueAsString(node);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new RuntimeException("Failed to serialize JSON", e);
}
}
private void endCall(final long callId, final String reason) {
var state = activeCalls.remove(callId);
if (state == null) return;
state.state = CallInfo.State.ENDED;
fireCallEvent(state, 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)
&& !"ringrtc_hangup".equals(reason)) {
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);
}
}
// Send hangup via control channel (stdin) before killing process
if (state.controlWriter != null) {
try {
var hangupMsg = mapper.createObjectNode();
hangupMsg.put("type", "hangup");
state.controlWriter.println(writeJson(hangupMsg));
state.controlWriter.close();
} catch (Exception e) {
logger.debug("Failed to send hangup via control channel", e);
}
}
// Kill tunnel process
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
state.tunnelProcess.destroy();
}
}
private void handleRingTimeout(final long callId) {
var state = activeCalls.get(callId);
if (state == null) return;
if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) {
logger.debug("Call {} ring timeout", callIdUnsigned(callId));
endCall(callId, "ring_timeout");
}
}
private static long generateCallId() {
return new BigInteger(64, new SecureRandom()).longValue();
}
@Override
public void close() {
scheduler.shutdownNow();
for (var callId : new ArrayList<>(activeCalls.keySet())) {
endCall(callId, "shutdown");
}
synchronized (callEventListeners) {
callEventListeners.clear();
}
}
// --- Internal call state tracking ---
static class CallState {
final long callId;
volatile CallInfo.State state;
final RecipientId recipientId;
volatile Integer deviceId;
final boolean isOutgoing;
volatile String inputDeviceName;
volatile String outputDeviceName;
volatile Process tunnelProcess;
volatile PrintWriter controlWriter;
// Control messages queued before the tunnel process starts
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)
volatile boolean tunnelRinging = false;
CallState(
long callId,
CallInfo.State state,
RecipientId recipientId,
final Integer deviceId,
boolean isOutgoing
) {
this.callId = callId;
this.state = state;
this.recipientId = recipientId;
this.deviceId = deviceId;
this.isOutgoing = isOutgoing;
}
CallInfo toCallInfo(RecipientAddressResolver addressResolver) {
return new CallInfo(callId,
state,
addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(),
inputDeviceName,
outputDeviceName,
isOutgoing);
}
}
}

View File

@ -23,6 +23,7 @@ public class Context implements AutoCloseable {
private AccountHelper accountHelper;
private AttachmentHelper attachmentHelper;
private CallManager callManager;
private ContactHelper contactHelper;
private GroupHelper groupHelper;
private GroupV2Helper groupV2Helper;
@ -92,6 +93,10 @@ public class Context implements AutoCloseable {
return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this));
}
public CallManager getCallManager() {
return getOrCreate(() -> callManager, () -> callManager = new CallManager(this));
}
public ContactHelper getContactHelper() {
return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account));
}
@ -172,6 +177,9 @@ public class Context implements AutoCloseable {
@Override
public void close() {
if (callManager != null) {
callManager.close();
}
jobExecutor.close();
}

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;
@ -401,9 +402,54 @@ public final class IncomingMessageHandler {
longTexts.putAll(syncResults.second());
}
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId);
}
return new Pair<>(actions, longTexts);
}
private void handleCallMessage(
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
? 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, deviceId, offer.getId(), type, offer.getOpaque());
});
callMessage.getAnswerMessage()
.ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque()));
callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> {
for (var ice : iceUpdates) {
callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque(), deviceId);
}
});
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()));
}
private boolean handlePniSignatureMessage(
final SignalServicePniSignatureMessage message,
final SignalServiceAddress senderAddress
@ -615,10 +661,6 @@ public final class IncomingMessageHandler {
final var aep = keysMessage.getAccountEntropyPool();
account.setAccountEntropyPool(aep);
actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getMaster() != null) {
final var masterKey = keysMessage.getMaster();
account.setMasterKey(masterKey);
actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getStorageService() != null) {
final var storageKey = keysMessage.getStorageService();
account.setStorageKey(storageKey);

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

@ -258,7 +258,6 @@ public class SyncHelper {
public SendMessageResult sendKeysMessage() {
var keysMessage = new KeysMessage(account.getOrCreateStorageKey(),
account.getOrCreatePinMasterKey(),
account.getOrCreateAccountEntropyPool(),
account.getOrCreateMediaRootBackupKey());
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage));

View File

@ -19,6 +19,8 @@ package org.asamk.signal.manager.internal;
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.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
@ -62,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;
@ -105,6 +108,12 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
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.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
@ -701,9 +710,9 @@ public class ManagerImpl implements Manager {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
} else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSendHelper()
.sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp, urgent);
.sendAsGroupMessage(messageBuilder, groupId, notifySelf, editTargetTimestamp, urgent);
results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
}
}
@ -843,7 +852,8 @@ public class ManagerImpl implements Manager {
messageBuilder.withBody(message.messageText());
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments(), message.voiceNote());
final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);
@ -949,12 +959,10 @@ public class ManagerImpl implements Manager {
var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid u) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid()));
} else if (recipient instanceof RecipientIdentifier.Pni pni) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) {
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
} else if (recipient instanceof RecipientIdentifier.Pni(var pni)) {
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
} else if (recipient instanceof RecipientIdentifier.Single r) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@ -965,8 +973,8 @@ public class ManagerImpl implements Manager {
}
} catch (UnregisteredRecipientException ignored) {
}
} else if (recipient instanceof RecipientIdentifier.Group r) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId());
} else if (recipient instanceof RecipientIdentifier.Group(var groupId)) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId);
}
}
return sendMessage(messageBuilder, recipients, false);
@ -1139,8 +1147,8 @@ public class ManagerImpl implements Manager {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId());
} else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId);
results.put(recipient, List.of(toSendMessageResult(result)));
}
}
@ -1154,7 +1162,7 @@ public class ManagerImpl implements Manager {
final List<String> options,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate);
return sendMessage(messageBuilder, recipients, notifySelf);
@ -1186,7 +1194,7 @@ public class ManagerImpl implements Manager {
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate);
return sendMessage(messageBuilder, recipients, notifySelf);
@ -1704,6 +1712,16 @@ public class ManagerImpl implements Manager {
}
}
@Override
public void addCallEventListener(final CallEventListener listener) {
context.getCallManager().addCallEventListener(listener);
}
@Override
public void removeCallEventListener(final CallEventListener listener) {
context.getCallManager().removeCallEventListener(listener);
}
@Override
public InputStream retrieveAttachment(final String id) throws IOException {
return context.getAttachmentHelper().retrieveAttachment(id).getStream();
@ -1761,6 +1779,132 @@ public class ManagerImpl implements Manager {
return streamDetails.getStream();
}
// --- Voice call methods ---
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
return context.getCallManager().startOutgoingCall(recipientId);
}
@Override
public CallInfo acceptCall(final long callId) throws IOException {
return context.getCallManager().acceptIncomingCall(callId);
}
@Override
public void hangupCall(final long callId) throws IOException {
context.getCallManager().hangupCall(callId);
}
@Override
public SendMessageResult rejectCall(final long callId) throws IOException {
final var result = context.getCallManager().rejectCall(callId);
return toSendMessageResult(result);
}
@Override
public List<CallInfo> listActiveCalls() {
return context.getCallManager().listActiveCalls();
}
@Override
public void sendCallOffer(
final RecipientIdentifier.Single recipient,
final CallOffer offer
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var offerMessage = new OfferMessage(offer.callId(),
offer.type() == CallOffer.Type.VIDEO ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL,
offer.opaque());
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendCallAnswer(
final RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var answerMessage = new AnswerMessage(callId, answerOpaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendIceUpdate(
final RecipientIdentifier.Single recipient,
final long callId,
final List<byte[]> iceCandidates
) 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 callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendHangup(
final RecipientIdentifier.Single recipient,
final long callId,
final MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var hangupType = switch (type) {
case NORMAL -> HangupMessage.Type.NORMAL;
case ACCEPTED -> HangupMessage.Type.ACCEPTED;
case DECLINED -> HangupMessage.Type.DECLINED;
case BUSY -> HangupMessage.Type.BUSY;
case NEED_PERMISSION -> HangupMessage.Type.NEED_PERMISSION;
};
var hangupMessage = new HangupMessage(callId, hangupType, 0);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendBusy(
final RecipientIdentifier.Single recipient,
final long callId
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public List<TurnServer> getTurnServerInfo() throws IOException {
return context.getCallManager().getTurnServers();
}
@Override
public void close() {
Thread thread;

View File

@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
ret.getAciIdentity(),
ret.getPniIdentity(),
profileKey,
ret.getMasterKey(),
ret.getAccountEntropyPool(),
ret.getMediaRootBackupKey());

View File

@ -15,6 +15,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.calling.CallingApi;
import org.whispersystems.signalservice.api.cds.CdsApi;
import org.whispersystems.signalservice.api.certificate.CertificateApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
@ -76,6 +77,7 @@ public class SignalDependencies {
private StorageServiceApi storageServiceApi;
private CertificateApi certificateApi;
private AttachmentApi attachmentApi;
private CallingApi callingApi;
private MessageApi messageApi;
private KeysApi keysApi;
private GroupsV2Operations groupsV2Operations;
@ -255,6 +257,13 @@ public class SignalDependencies {
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
}
public CallingApi getCallingApi() {
return getOrCreate(() -> callingApi,
() -> callingApi = new CallingApi(getAuthenticatedSignalWebSocket(),
getUnauthenticatedSignalWebSocket(),
getPushServiceSocket()));
}
public MessageApi getMessageApi() {
return getOrCreate(() -> messageApi,
() -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(),

View File

@ -292,7 +292,6 @@ public class SignalAccount implements Closeable {
final IdentityKeyPair aciIdentity,
final IdentityKeyPair pniIdentity,
final ProfileKey profileKey,
final MasterKey masterKey,
final AccountEntropyPool accountEntropyPool,
final MediaRootBackupKey mediaRootBackupKey
) {
@ -314,7 +313,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
} else {
this.pinMasterKey = masterKey;
this.pinMasterKey = null;
this.accountEntropyPool = null;
}
this.mediaRootBackupKey = mediaRootBackupKey;

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

@ -0,0 +1,432 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.CallInfo;
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;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.math.BigInteger;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for pure functions and state machine logic in CallManager.
* Uses reflection to access private static helpers without changing production visibility.
*/
class CallManagerTest {
// --- Reflection helpers for private static methods ---
private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES;
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",
MethodType.methodType(byte[].class, byte[].class));
CALL_ID_UNSIGNED = lookup.findStatic(Utils.class,
"callIdUnsigned",
MethodType.methodType(BigInteger.class, long.class));
GENERATE_CALL_ID = lookup.findStatic(CallManager.class,
"generateCallId",
MethodType.methodType(long.class));
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) throws Throwable {
return (byte[]) GET_RAW_IDENTITY_KEY_BYTES.invokeExact(serializedKey);
}
private static BigInteger callIdUnsigned(long callId) throws Throwable {
return (BigInteger) CALL_ID_UNSIGNED.invokeExact(callId);
}
private static long generateCallId() throws Throwable {
return (long) GENERATE_CALL_ID.invokeExact();
}
// --- Helper to create a minimal CallState for state machine tests ---
private static CallManager.CallState makeCallState(long callId, CallInfo.State initialState) {
return new CallManager.CallState(callId, initialState, TestRecipientId.createTestId(15551234567L), null, true);
}
// ========================================================================
// getRawIdentityKeyBytes tests
// ========================================================================
@Test
void getRawIdentityKeyBytes_strips0x05Prefix() throws Throwable {
// 33-byte key with 0x05 DJB type prefix
var key33 = new byte[33];
key33[0] = 0x05;
for (int i = 1; i < 33; i++) key33[i] = (byte) i;
var result = getRawIdentityKeyBytes(key33);
assertEquals(32, result.length);
for (int i = 0; i < 32; i++) {
assertEquals((byte) (i + 1), result[i]);
}
}
@Test
void getRawIdentityKeyBytes_already32Bytes() throws Throwable {
var key32 = new byte[32];
for (int i = 0; i < 32; i++) key32[i] = (byte) (i + 10);
var result = getRawIdentityKeyBytes(key32);
assertArrayEquals(key32, result);
}
@Test
void getRawIdentityKeyBytes_33BytesWrongPrefix() throws Throwable {
// 33 bytes but prefix is NOT 0x05
var key33 = new byte[33];
key33[0] = 0x07;
for (int i = 1; i < 33; i++) key33[i] = (byte) i;
var result = getRawIdentityKeyBytes(key33);
// Should return the original key unchanged
assertArrayEquals(key33, result);
assertEquals(33, result.length);
}
@Test
void getRawIdentityKeyBytes_emptyArray() throws Throwable {
var empty = new byte[0];
var result = getRawIdentityKeyBytes(empty);
assertArrayEquals(empty, result);
}
@Test
void getRawIdentityKeyBytes_shortArray() throws Throwable {
var short5 = new byte[]{0x05, 1, 2};
var result = getRawIdentityKeyBytes(short5);
// Not 33 bytes, so returned unchanged despite 0x05 prefix
assertArrayEquals(short5, result);
}
// ========================================================================
// callIdUnsigned tests
// ========================================================================
@Test
void callIdUnsigned_zero() throws Throwable {
assertEquals(BigInteger.ZERO, callIdUnsigned(0L));
}
@Test
void callIdUnsigned_positiveLong() throws Throwable {
assertEquals(new BigInteger("8230211930154373276"), callIdUnsigned(8230211930154373276L));
}
@Test
void callIdUnsigned_negativeLongBecomesUnsigned() throws Throwable {
// -1L as unsigned is 2^64 - 1 = 18446744073709551615
assertEquals(new BigInteger("18446744073709551615"), callIdUnsigned(-1L));
}
@Test
void callIdUnsigned_longMinValueBecomesUnsigned() throws Throwable {
// Long.MIN_VALUE as unsigned is 2^63 = 9223372036854775808
assertEquals(new BigInteger("9223372036854775808"), callIdUnsigned(Long.MIN_VALUE));
}
@Test
void callIdUnsigned_longMaxValue() throws Throwable {
assertEquals(new BigInteger("9223372036854775807"), callIdUnsigned(Long.MAX_VALUE));
}
// ========================================================================
// generateCallId tests
// ========================================================================
@Test
void generateCallId_producesVariation() throws Throwable {
long first = generateCallId();
boolean foundDifferent = false;
for (int i = 0; i < 20; i++) {
if (generateCallId() != first) {
foundDifferent = true;
break;
}
}
assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row");
}
// ========================================================================
// handleStateChange state machine tests
//
// Since handleStateChange is a private instance method requiring a full
// CallManager (which needs Context), we test the state transition logic
// directly by reproducing its documented rules against CallState.
// The rules are:
// "Incoming*" -> RINGING_INCOMING (unless already CONNECTING)
// "Outgoing*" -> RINGING_OUTGOING
// "Ringing" -> triggers deferred accept (no state change)
// "Connected" -> CONNECTED
// "Connecting"-> RECONNECTING
// "Ended"/"Rejected" -> would call endCall (sets ENDED)
// "Concluded" -> no-op
// ========================================================================
@Test
void stateTransition_incomingToRingingIncoming() {
var state = makeCallState(1L, CallInfo.State.IDLE);
applyStateTransition(state, "Incoming(Audio)", null);
assertEquals(CallInfo.State.RINGING_INCOMING, state.state);
}
@Test
void stateTransition_incomingWithMediaType() {
var state = makeCallState(1L, CallInfo.State.IDLE);
applyStateTransition(state, "Incoming(Video)", null);
assertEquals(CallInfo.State.RINGING_INCOMING, state.state);
}
@Test
void stateTransition_incomingDoesNotDowngradeFromConnecting() {
var state = makeCallState(1L, CallInfo.State.CONNECTING);
applyStateTransition(state, "Incoming(Audio)", null);
// Must remain CONNECTING, not downgraded to RINGING_INCOMING
assertEquals(CallInfo.State.CONNECTING, state.state);
}
@Test
void stateTransition_outgoing() {
var state = makeCallState(1L, CallInfo.State.IDLE);
applyStateTransition(state, "Outgoing(Audio)", null);
assertEquals(CallInfo.State.RINGING_OUTGOING, state.state);
}
@Test
void stateTransition_connected() {
var state = makeCallState(1L, CallInfo.State.CONNECTING);
applyStateTransition(state, "Connected", null);
assertEquals(CallInfo.State.CONNECTED, state.state);
}
@Test
void stateTransition_connectingMapsToReconnecting() {
// "Connecting" from RingRTC means ICE reconnection, not initial connect
var state = makeCallState(1L, CallInfo.State.CONNECTED);
applyStateTransition(state, "Connecting", null);
assertEquals(CallInfo.State.RECONNECTING, state.state);
}
@Test
void stateTransition_ringingDoesNotChangeState() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
applyStateTransition(state, "Ringing", null);
// "Ringing" triggers sendAcceptIfReady but doesn't change state
assertEquals(CallInfo.State.RINGING_INCOMING, state.state);
}
@Test
void stateTransition_ringSetsAcceptPendingFalseWhenReady() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
state.acceptPending = true;
// No controlWriter set, so accept won't actually send but acceptPending stays true
// This documents the behavior: without a controlWriter, deferred accept stays pending
applyStateTransition(state, "Ringing", null);
assertTrue(state.acceptPending, "acceptPending should remain true when controlWriter is null");
}
@Test
void stateTransition_concludedIsNoop() {
var state = makeCallState(1L, CallInfo.State.CONNECTED);
applyStateTransition(state, "Concluded", null);
// State should NOT change
assertEquals(CallInfo.State.CONNECTED, state.state);
}
@Test
void stateTransition_endedSetsEnded() {
var state = makeCallState(1L, CallInfo.State.CONNECTED);
applyStateTransition(state, "Ended", "Timeout");
// endCall would set ENDED (we simulate that since endCall is instance method)
assertEquals(CallInfo.State.ENDED, state.state);
}
@Test
void stateTransition_rejectedSetsEnded() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
applyStateTransition(state, "Rejected", "BusyOnAnotherDevice");
assertEquals(CallInfo.State.ENDED, state.state);
}
@Test
void stateTransition_endedWithNullReasonUsesStateName() {
var state = makeCallState(1L, CallInfo.State.CONNECTED);
// When reason is null, endCall should be called with state name lowercased
// We verify state becomes ENDED (the reason defaulting logic is in handleStateChange)
applyStateTransition(state, "Ended", null);
assertEquals(CallInfo.State.ENDED, state.state);
}
@Test
void stateTransition_unknownStateIsNoop() {
var state = makeCallState(1L, CallInfo.State.CONNECTED);
applyStateTransition(state, "SomeUnknownState", null);
// No matching branch, state unchanged
assertEquals(CallInfo.State.CONNECTED, state.state);
}
// ========================================================================
// endCall guard condition tests
//
// endCall sends a Signal protocol hangup UNLESS the reason indicates the
// remote side already knows (remote_hangup, rejected, remote_busy, ringrtc_hangup).
// We test this logic directly.
// ========================================================================
@ParameterizedTest
@ValueSource(strings = {"remote_hangup", "rejected", "remote_busy", "ringrtc_hangup"})
void endCallGuard_remoteCausesSkipHangup(String reason) {
// These reasons should NOT trigger sending a hangup to the remote
assertTrue(shouldSkipRemoteHangup(reason));
}
@ParameterizedTest
@ValueSource(strings = {"local_hangup", "ring_timeout", "tunnel_exit", "tunnel_error", "shutdown"})
void endCallGuard_localCausesSendHangup(String reason) {
// These reasons SHOULD trigger sending a hangup to the remote
assertTrue(shouldSendRemoteHangup(reason));
}
// ========================================================================
// CallState.toCallInfo tests
// ========================================================================
@Test
void callState_toCallInfo() {
var state = makeCallState(42L, CallInfo.State.CONNECTED);
state.inputDeviceName = "test_input";
state.outputDeviceName = "test_output";
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(42L, info.callId());
assertEquals(CallInfo.State.CONNECTED, info.state());
assertEquals("RecipientId[id=15551234567]", info.recipient().number().orElse(null));
assertTrue(info.isOutgoing());
assertEquals("test_input", info.inputDeviceName());
assertEquals("test_output", info.outputDeviceName());
}
@Test
void callState_toCallInfoNullDeviceNames() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(CallInfo.State.RINGING_INCOMING, info.state());
assertEquals(null, info.inputDeviceName());
assertEquals(null, info.outputDeviceName());
}
// ========================================================================
// tunnelBinaryFromCodeSourcePath tests
//
// The install dir is derived from the code source location (jar or class
// directory): go up two levels (out of lib/) to reach the install root,
// then resolve bin/signal-call-tunnel.
// ========================================================================
@Test
void tunnelBinaryFromCodeSourcePath_resolvesFromJarInLib() {
// Simulate: /opt/signal-cli/lib/signal-cli.jar
var jarPath = Path.of("/opt/signal-cli/lib/signal-cli.jar");
var result = CallManager.tunnelBinaryFromCodeSourcePath(jarPath);
assertEquals(Path.of("/opt/signal-cli/bin/signal-call-tunnel"), result);
}
@Test
void tunnelBinaryFromCodeSourcePath_resolvesFromClassDir() {
// In dev/test, code source is a directory like build/classes/java/main
var classDir = Path.of("/project/lib/build/classes/java/main");
var result = CallManager.tunnelBinaryFromCodeSourcePath(classDir);
// Goes up two levels from main -> classes, then looks for bin/signal-call-tunnel
assertEquals(Path.of("/project/lib/build/classes/bin/signal-call-tunnel"), result);
}
@Test
void tunnelBinaryFromCodeSourcePath_deeplyNestedPath() {
var jarPath = Path.of("/home/user/.local/share/signal-cli/lib/signal-cli.jar");
var result = CallManager.tunnelBinaryFromCodeSourcePath(jarPath);
assertEquals(Path.of("/home/user/.local/share/signal-cli/bin/signal-call-tunnel"), result);
}
// ========================================================================
// Helpers that reproduce the documented logic from handleStateChange and
// endCall, allowing us to verify the state machine rules without needing
// a full CallManager instance (which requires Context/SignalAccount/etc).
// ========================================================================
/**
* Reproduces the state transition logic from CallManager.handleStateChange.
* This directly mirrors the production code's branching to verify correctness.
*/
private static void applyStateTransition(CallManager.CallState state, String ringrtcState, String reason) {
if (ringrtcState.startsWith("Incoming")) {
if (state.state == CallInfo.State.CONNECTING) return;
state.state = CallInfo.State.RINGING_INCOMING;
} else if (ringrtcState.startsWith("Outgoing")) {
state.state = CallInfo.State.RINGING_OUTGOING;
} else if ("Ringing".equals(ringrtcState)) {
// Would call sendAcceptIfReady tested separately
return;
} else if ("Connected".equals(ringrtcState)) {
state.state = CallInfo.State.CONNECTED;
} else if ("Connecting".equals(ringrtcState)) {
state.state = CallInfo.State.RECONNECTING;
} else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) {
// Simplified: just set ENDED (production code calls endCall which does cleanup + sets ENDED)
state.state = CallInfo.State.ENDED;
return;
} else if ("Concluded".equals(ringrtcState)) {
return;
}
}
/**
* Reproduces the endCall guard condition: returns true when a Signal protocol
* hangup should NOT be sent to the remote peer.
*/
private static boolean shouldSkipRemoteHangup(String reason) {
return "remote_hangup".equals(reason)
|| "rejected".equals(reason)
|| "remote_busy".equals(reason)
|| "ringrtc_hangup".equals(reason);
}
/**
* Inverse of shouldSkipRemoteHangup.
*/
private static boolean shouldSendRemoteHangup(String reason) {
return !shouldSkipRemoteHangup(reason);
}
}

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

@ -0,0 +1,77 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import java.io.IOException;
public class AcceptCallCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "acceptCall";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Accept an incoming voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to accept.");
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
if (!(ns.get("call-id") instanceof Number callIdNumber)) {
throw new UserErrorException("No call ID given");
}
final long callId = callIdNumber.longValue();
try {
var callInfo = m.acceptCall(callId);
switch (outputWriter) {
case PlainTextWriter writer -> {
writer.println("Call accepted:");
writer.println(" Call ID: {}", callInfo.callId());
writer.println(" State: {}", callInfo.state());
writer.println(" Input device: {}", callInfo.inputDeviceName());
writer.println(" Output device: {}", callInfo.outputDeviceName());
}
case JsonWriter writer -> writer.write(new JsonCallInfo(callInfo.callId(),
callInfo.state().name(),
callInfo.inputDeviceName(),
callInfo.outputDeviceName(),
"opus",
48000,
1,
20));
}
} catch (IOException e) {
throw new IOErrorException("Failed to accept call: " + e.getMessage(), e);
}
}
private record JsonCallInfo(
long callId,
String state,
String inputDeviceName,
String outputDeviceName,
String codec,
int sampleRate,
int channels,
int ptimeMs
) {}
}

View File

@ -10,18 +10,21 @@ public class Commands {
private static final Map<String, SubparserAttacher> commandSubparserAttacher = new TreeMap<>();
static {
addCommand(new AcceptCallCommand());
addCommand(new AddDeviceCommand());
addCommand(new BlockCommand());
addCommand(new DaemonCommand());
addCommand(new DeleteLocalAccountDataCommand());
addCommand(new FinishChangeNumberCommand());
addCommand(new FinishLinkCommand());
addCommand(new HangupCallCommand());
addCommand(new GetAttachmentCommand());
addCommand(new GetAvatarCommand());
addCommand(new GetStickerCommand());
addCommand(new GetUserStatusCommand());
addCommand(new AddStickerPackCommand());
addCommand(new JoinGroupCommand());
addCommand(new ListCallsCommand());
addCommand(new JsonRpcDispatcherCommand());
addCommand(new LinkCommand());
addCommand(new ListAccountsCommand());
@ -32,6 +35,7 @@ public class Commands {
addCommand(new ListStickerPacksCommand());
addCommand(new QuitGroupCommand());
addCommand(new ReceiveCommand());
addCommand(new RejectCallCommand());
addCommand(new RegisterCommand());
addCommand(new RemoveContactCommand());
addCommand(new RemoveDeviceCommand());
@ -52,6 +56,7 @@ public class Commands {
addCommand(new SendTypingCommand());
addCommand(new SendUnpinMessageCommand());
addCommand(new SetPinCommand());
addCommand(new StartCallCommand());
addCommand(new SubmitRateLimitChallengeCommand());
addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand());

View File

@ -0,0 +1,47 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.output.OutputWriter;
import java.io.IOException;
public class HangupCallCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "hangupCall";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Hang up an active voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to hang up.");
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
if (!(ns.get("call-id") instanceof Number callIdNumber)) {
throw new UserErrorException("No call ID given");
}
final long callId = callIdNumber.longValue();
try {
m.hangupCall(callId);
} catch (IOException e) {
throw new IOErrorException("Failed to hang up call: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,79 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import java.util.List;
public class ListCallsCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "listCalls";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("List active voice calls.");
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
var calls = m.listActiveCalls();
switch (outputWriter) {
case PlainTextWriter writer -> {
if (calls.isEmpty()) {
writer.println("No active calls.");
} else {
for (var call : calls) {
writer.println("- Call {}:", call.callId());
writer.indent(w -> {
w.println("State: {}", call.state());
w.println("Recipient: {}", call.recipient());
w.println("Direction: {}", call.isOutgoing() ? "outgoing" : "incoming");
if (call.inputDeviceName() != null) {
w.println("Input device: {}", call.inputDeviceName());
}
if (call.outputDeviceName() != null) {
w.println("Output device: {}", call.outputDeviceName());
}
});
}
}
}
case JsonWriter writer -> {
var jsonCalls = calls.stream()
.map(c -> new JsonCall(c.callId(),
c.state().name(),
c.recipient().number().orElse(null),
c.recipient().uuid().map(java.util.UUID::toString).orElse(null),
c.isOutgoing(),
c.inputDeviceName(),
c.outputDeviceName()))
.toList();
writer.write(jsonCalls);
}
}
}
private record JsonCall(
long callId,
String state,
String number,
String uuid,
boolean isOutgoing,
String inputDeviceName,
String outputDeviceName
) {}
}

View File

@ -0,0 +1,47 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.output.OutputWriter;
import java.io.IOException;
public class RejectCallCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "rejectCall";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Reject an incoming voice call.");
subparser.addArgument("--call-id")
.type(long.class)
.required(true)
.help("The call ID to reject.");
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
if (!(ns.get("call-id") instanceof Number callIdNumber)) {
throw new UserErrorException("No call ID given");
}
final long callId = callIdNumber.longValue();
try {
m.rejectCall(callId);
} catch (IOException e) {
throw new IOErrorException("Failed to reject call: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,80 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
public class StartCallCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "startCall";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Start an outgoing voice call.");
subparser.addArgument("recipient").help("Specify the recipient's phone number or UUID.").nargs(1);
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var recipientStrings = ns.<String>getList("recipient");
if (recipientStrings == null || recipientStrings.isEmpty()) {
throw new UserErrorException("No recipient given");
}
final var recipient = CommandUtil.getSingleRecipientIdentifier(recipientStrings.getFirst(), m.getSelfNumber());
try {
var callInfo = m.startCall(recipient);
switch (outputWriter) {
case PlainTextWriter writer -> {
writer.println("Call started:");
writer.println(" Call ID: {}", callInfo.callId());
writer.println(" State: {}", callInfo.state());
writer.println(" Input device: {}", callInfo.inputDeviceName());
writer.println(" Output device: {}", callInfo.outputDeviceName());
}
case JsonWriter writer -> writer.write(new JsonCallInfo(callInfo.callId(),
callInfo.state().name(),
callInfo.inputDeviceName(),
callInfo.outputDeviceName(),
"opus",
48000,
1,
20));
}
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("Recipient not registered: " + e.getMessage(), e);
} catch (IOException e) {
throw new IOErrorException("Failed to start call: " + e.getMessage(), e);
}
}
private record JsonCallInfo(
long callId,
String state,
String inputDeviceName,
String outputDeviceName,
String codec,
int sampleRate,
int channels,
int ptimeMs
) {}
}

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;
@ -912,6 +916,85 @@ public class DbusManagerImpl implements Manager {
}
}
@Override
public void addCallEventListener(final CallEventListener listener) {
// Not supported over DBus
}
@Override
public void removeCallEventListener(final CallEventListener listener) {
// Not supported over DBus
}
// --- Voice call methods (not supported over DBus) ---
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public CallInfo acceptCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void hangupCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public SendMessageResult rejectCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<CallInfo> listActiveCalls() {
return java.util.List.of();
}
@Override
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 RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
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 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 RecipientIdentifier.Single recipient, final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<TurnServer> getTurnServerInfo() {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void close() {
synchronized (this) {

View File

@ -0,0 +1,32 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.asamk.signal.manager.api.CallInfo;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
public record JsonCallEvent(
long callId,
String state,
@JsonInclude(NON_NULL) String number,
@JsonInclude(NON_NULL) String uuid,
boolean isOutgoing,
@JsonInclude(NON_NULL) String inputDeviceName,
@JsonInclude(NON_NULL) String outputDeviceName,
@JsonInclude(NON_NULL) String reason
) {
public static JsonCallEvent from(CallInfo callInfo, String reason) {
return new JsonCallEvent(
callInfo.callId(),
callInfo.state().name(),
callInfo.recipient().number().orElse(null),
callInfo.recipient().aci().orElse(null),
callInfo.isOutgoing(),
callInfo.inputDeviceName(),
callInfo.outputDeviceName(),
reason
);
}
}

View File

@ -5,9 +5,13 @@ import io.micronaut.jsonschema.JsonSchema;
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;
@JsonSchema(title = "CallMessage")
record JsonCallMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage,
@ -25,38 +29,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

@ -14,6 +14,7 @@ import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcSingleCommand;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.json.JsonCallEvent;
import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
@ -24,6 +25,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.channels.ClosedChannelException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -40,6 +42,7 @@ public class SignalJsonRpcDispatcherHandler {
private final boolean noReceiveOnStart;
private final Map<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
private final Map<Integer, List<Pair<Manager, Manager.CallEventListener>>> callEventHandlers = new HashMap<>();
private SignalJsonRpcCommandHandler commandHandler;
public SignalJsonRpcDispatcherHandler(
@ -61,6 +64,10 @@ public class SignalJsonRpcDispatcherHandler {
c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
c.addOnManagerRemovedHandler(this::unsubscribeReceive);
}
c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createReceiveHandler(m, subscriptionId, false))));
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createCallEventHandler(m, subscriptionId))));
handleConnection();
}
@ -78,6 +85,57 @@ public class SignalJsonRpcDispatcherHandler {
handleConnection();
}
private int subscribeCallEvents(final Manager manager) {
return subscribeCallEvents(List.of(manager));
}
private int subscribeCallEvents(final Collection<Manager> managers) {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var listeners = managers.stream().map(m -> createCallEventHandler(m, subscriptionId)).toList();
callEventHandlers.put(subscriptionId, listeners);
return subscriptionId;
}
private Pair<Manager, Manager.CallEventListener> createCallEventHandler(final Manager m, final int subscriptionId) {
final Manager.CallEventListener listener = (callInfo, reason) -> {
final var params = new ObjectNode(objectMapper.getNodeFactory());
params.set("subscription", IntNode.valueOf(subscriptionId));
params.set("result", objectMapper.valueToTree(JsonCallEvent.from(callInfo, reason)));
final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
unsubscribeReceive(subscriptionId);
}
}
};
m.addCallEventListener(listener);
return new Pair<>(m, listener);
}
private boolean unsubscribeCallEvents(final int subscriptionId) {
final var handlers = callEventHandlers.remove(subscriptionId);
if (handlers == null) {
return false;
}
for (final var pair : handlers) {
unsubscribeCallEventHandler(pair);
}
return true;
}
private void unsubscribeAllCallEvents() {
callEventHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeCallEventHandler));
callEventHandlers.clear();
}
private void unsubscribeCallEventHandler(final Pair<Manager, Manager.CallEventListener> pair) {
final var m = pair.first();
final var handler = pair.second();
m.removeCallEventListener(handler);
}
private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0);
private int subscribeReceive(final Manager manager, boolean internalSubscription) {
@ -86,34 +144,42 @@ public class SignalJsonRpcDispatcherHandler {
private int subscribeReceive(final List<Manager> managers, boolean internalSubscription) {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var handlers = managers.stream().map(m -> {
final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> {
ContainerNode<?> params;
if (internalSubscription) {
params = objectMapper.valueToTree(s);
} else {
final var paramsNode = new ObjectNode(objectMapper.getNodeFactory());
paramsNode.set("subscription", IntNode.valueOf(subscriptionId));
paramsNode.set("result", objectMapper.valueToTree(s));
params = paramsNode;
}
final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
unsubscribeReceive(subscriptionId);
}
}
});
m.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m, (Manager.ReceiveMessageHandler) receiveMessageHandler);
}).toList();
final var handlers = managers.stream()
.map(m -> createReceiveHandler(m, subscriptionId, internalSubscription))
.toList();
receiveHandlers.put(subscriptionId, handlers);
return subscriptionId;
}
private Pair<Manager, Manager.ReceiveMessageHandler> createReceiveHandler(
final Manager m,
final int subscriptionId,
final boolean internalSubscription
) {
final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> {
ContainerNode<?> params;
if (internalSubscription) {
params = objectMapper.valueToTree(s);
} else {
final var paramsNode = new ObjectNode(objectMapper.getNodeFactory());
paramsNode.set("subscription", IntNode.valueOf(subscriptionId));
paramsNode.set("result", objectMapper.valueToTree(s));
params = paramsNode;
}
final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
unsubscribeReceive(subscriptionId);
}
}
});
m.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m, receiveMessageHandler);
}
private boolean unsubscribeReceive(final int subscriptionId) {
final var handlers = receiveHandlers.remove(subscriptionId);
if (handlers == null) {
@ -141,6 +207,7 @@ public class SignalJsonRpcDispatcherHandler {
} finally {
receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler));
receiveHandlers.clear();
unsubscribeAllCallEvents();
}
}
@ -157,6 +224,12 @@ public class SignalJsonRpcDispatcherHandler {
if ("unsubscribeReceive".equals(method)) {
return new UnsubscribeReceiveCommand();
}
if ("subscribeCallEvents".equals(method)) {
return new SubscribeCallEventsCommand();
}
if ("unsubscribeCallEvents".equals(method)) {
return new UnsubscribeCallEventsCommand();
}
return Commands.getCommand(method);
}
@ -240,4 +313,85 @@ public class SignalJsonRpcDispatcherHandler {
};
}
}
private class SubscribeCallEventsCommand implements JsonRpcSingleCommand<Void>, JsonRpcMultiCommand<Void> {
@Override
public String getName() {
return "subscribeCallEvents";
}
@Override
public void handleCommand(
final Void request,
final Manager m,
final JsonWriter jsonWriter
) throws CommandException {
final var subscriptionId = subscribeCallEvents(m);
jsonWriter.write(subscriptionId);
}
@Override
public void handleCommand(
final Void request,
final MultiAccountManager c,
final JsonWriter jsonWriter
) throws CommandException {
final var subscriptionId = subscribeCallEvents(c.getManagers());
jsonWriter.write(subscriptionId);
}
}
private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand<JsonNode>, JsonRpcMultiCommand<JsonNode> {
@Override
public String getName() {
return "unsubscribeCallEvents";
}
@Override
public TypeReference<JsonNode> getRequestType() {
return new TypeReference<>() {};
}
@Override
public void handleCommand(
final JsonNode request,
final Manager m,
final JsonWriter jsonWriter
) throws CommandException {
final var subscriptionId = getSubscriptionId(request);
if (subscriptionId == null) {
throw new UserErrorException("Missing subscription parameter with subscription id");
} else {
if (!unsubscribeCallEvents(subscriptionId)) {
throw new UserErrorException("Unknown subscription id");
}
}
}
@Override
public void handleCommand(
final JsonNode request,
final MultiAccountManager c,
final JsonWriter jsonWriter
) throws CommandException {
final var subscriptionId = getSubscriptionId(request);
if (subscriptionId == null) {
throw new UserErrorException("Missing subscription parameter with subscription id");
} else {
if (!unsubscribeCallEvents(subscriptionId)) {
throw new UserErrorException("Unknown subscription id");
}
}
}
private Integer getSubscriptionId(final JsonNode request) {
return switch (request) {
case ArrayNode req -> req.get(0).asInt();
case ObjectNode req -> req.get("subscription").asInt();
case null, default -> null;
};
}
}
}

View File

@ -1082,6 +1082,12 @@
}
]
},
{
"type": "java.math.BigInteger"
},
{
"type": "java.math.BigInteger[]"
},
{
"type": "java.net.NetPermission"
},
@ -1961,6 +1967,48 @@
}
]
},
{
"type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "channels",
"parameterTypes": []
},
{
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
},
{
"name": "sampleRate",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams",
"allDeclaredFields": true,
@ -1998,6 +2046,20 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.commands.HangupCallCommand$JsonResult",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "status",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
"allDeclaredFields": true,
@ -2008,6 +2070,47 @@
}
]
},
{
"type": "org.asamk.signal.commands.ListCallsCommand$JsonCall",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "number",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.ListCallsCommand$JsonCall[]"
},
{
"type": "org.asamk.signal.commands.ListContactsCommand$JsonContact",
"allDeclaredFields": true,
@ -2186,6 +2289,62 @@
}
]
},
{
"type": "org.asamk.signal.commands.RejectCallCommand$JsonResult",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "status",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.StartCallCommand$JsonCallInfo",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "channels",
"parameterTypes": []
},
{
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
},
{
"name": "sampleRate",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.StartLinkCommand$JsonLink",
"allDeclaredFields": true,
@ -2292,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,
@ -7355,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,
@ -7935,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,
@ -9969,4 +10205,4 @@
"bundle": "net.sourceforge.argparse4j.internal.ArgumentParserImpl"
}
]
}
}

View File

@ -0,0 +1,79 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Verifies that call commands correctly handle call IDs from JSON-RPC,
* where Jackson may deserialize large numbers as BigInteger instead of Long.
*/
class CallCommandParsingTest {
/**
* Simulates what Jackson produces for a JSON-RPC call with a large call ID.
* Jackson deserializes numbers that overflow int as BigInteger in untyped maps.
*/
private static Namespace namespaceWithBigIntegerCallId(long value) {
// JsonRpcNamespace converts "call-id" to "callId" lookup
return new JsonRpcNamespace(Map.of("callId", BigInteger.valueOf(value)));
}
private static Namespace namespaceWithLongCallId(long value) {
return new JsonRpcNamespace(Map.of("callId", value));
}
@Test
void hangupCallHandlesBigIntegerCallId() {
var ns = namespaceWithBigIntegerCallId(8230211930154373276L);
var callIdNumber = ns.get("call-id");
long callId = ((Number) callIdNumber).longValue();
assertEquals(8230211930154373276L, callId);
}
@Test
void hangupCallHandlesLongCallId() {
var ns = namespaceWithLongCallId(8230211930154373276L);
var callIdNumber = ns.get("call-id");
long callId = ((Number) callIdNumber).longValue();
assertEquals(8230211930154373276L, callId);
}
@Test
void acceptCallHandlesBigIntegerCallId() {
var ns = namespaceWithBigIntegerCallId(1234567890123456789L);
var callIdNumber = ns.get("call-id");
long callId = ((Number) callIdNumber).longValue();
assertEquals(1234567890123456789L, callId);
}
@Test
void rejectCallHandlesBigIntegerCallId() {
var ns = namespaceWithBigIntegerCallId(Long.MAX_VALUE);
var callIdNumber = ns.get("call-id");
long callId = ((Number) callIdNumber).longValue();
assertEquals(Long.MAX_VALUE, callId);
}
@Test
void camelCaseKeyLookupWorks() {
// Verify JsonRpcNamespace maps "call-id" -> "callId"
var ns = new JsonRpcNamespace(Map.of("callId", BigInteger.valueOf(42L)));
Number result = ns.get("call-id");
assertEquals(42L, result.longValue());
}
@Test
void smallIntegerCallIdWorks() {
// Jackson may produce Integer for small values
var ns = new JsonRpcNamespace(Map.of("callId", 42));
var callIdNumber = ns.get("call-id");
long callId = ((Number) callIdNumber).longValue();
assertEquals(42L, callId);
}
}

View File

@ -0,0 +1,110 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.RecipientAddress;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonCallEventTest {
@Test
void fromWithNumberAndUuid() {
var recipient = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null);
var callInfo = new CallInfo(123L, CallInfo.State.CONNECTED, recipient, "signal_input_123", "signal_output_123", true);
var event = JsonCallEvent.from(callInfo, null);
assertEquals(123L, event.callId());
assertEquals("CONNECTED", event.state());
assertEquals("+15551234567", event.number());
assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890", event.uuid());
assertTrue(event.isOutgoing());
assertEquals("signal_input_123", event.inputDeviceName());
assertEquals("signal_output_123", event.outputDeviceName());
assertNull(event.reason());
}
@Test
void fromWithUuidOnly() {
var recipient = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, null, null);
var callInfo = new CallInfo(456L, CallInfo.State.RINGING_INCOMING, recipient, "signal_input_456", "signal_output_456", false);
var event = JsonCallEvent.from(callInfo, null);
assertEquals(456L, event.callId());
assertEquals("RINGING_INCOMING", event.state());
assertNull(event.number());
assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890", event.uuid());
assertFalse(event.isOutgoing());
}
@Test
void fromWithNumberOnly() {
var recipient = new RecipientAddress(null, null, "+15559876543", null);
var callInfo = new CallInfo(789L, CallInfo.State.RINGING_OUTGOING, recipient, "signal_input_789", "signal_output_789", true);
var event = JsonCallEvent.from(callInfo, null);
assertEquals("+15559876543", event.number());
assertNull(event.uuid());
}
@Test
void fromWithEndedStateAndReason() {
var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null);
var callInfo = new CallInfo(101L, CallInfo.State.ENDED, recipient, null, null, false);
var event = JsonCallEvent.from(callInfo, "remote_hangup");
assertEquals("ENDED", event.state());
assertEquals("remote_hangup", event.reason());
}
@Test
void fromMapsAllStates() {
var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null);
for (var state : CallInfo.State.values()) {
var callInfo = new CallInfo(1L, state, recipient, "signal_input_1", "signal_output_1", true);
var event = JsonCallEvent.from(callInfo, null);
assertEquals(state.name(), event.state());
}
}
@Test
void fromConnectingState() {
var recipient = new RecipientAddress("uuid-5678", null, "+15552222222", null);
var callInfo = new CallInfo(200L, CallInfo.State.CONNECTING, recipient, "signal_input_200", "signal_output_200", true);
var event = JsonCallEvent.from(callInfo, null);
assertEquals(200L, event.callId());
assertEquals("CONNECTING", event.state());
assertEquals("signal_input_200", event.inputDeviceName());
assertEquals("signal_output_200", event.outputDeviceName());
assertTrue(event.isOutgoing());
assertNull(event.reason());
}
@Test
void fromWithVariousEndReasons() {
var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null);
var reasons = new String[]{"local_hangup", "remote_hangup", "rejected", "remote_busy",
"ring_timeout", "ice_failed", "tunnel_exit", "tunnel_error", "shutdown"};
for (var reason : reasons) {
var callInfo = new CallInfo(1L, CallInfo.State.ENDED, recipient, null, null, false);
var event = JsonCallEvent.from(callInfo, reason);
assertEquals(reason, event.reason());
assertEquals("ENDED", event.state());
}
}
}

View File

@ -0,0 +1,782 @@
package org.asamk.signal.jsonrpc;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.ProvisioningManager;
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.InputStream;
import java.net.URI;
import java.time.Duration;
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.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands
* introduced in commit d1e93dd.
*/
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) {
lines.add(line);
}
String getLine() {
return lines.poll();
}
}
/**
* Captures JSON-RPC responses written by the handler.
*/
private static class CapturingJsonWriter implements JsonWriter {
final List<Object> written = Collections.synchronizedList(new ArrayList<>());
@Override
public void write(final Object object) {
written.add(object);
}
}
/**
* 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);
final String selfNumber;
StubManager(String selfNumber) {
this.selfNumber = selfNumber;
}
@Override
public void addCallEventListener(CallEventListener listener) {
addCount.incrementAndGet();
listeners.add(listener);
}
@Override
public void removeCallEventListener(CallEventListener listener) {
removeCount.incrementAndGet();
listeners.remove(listener);
}
@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 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<>();
StubMultiAccountManager(List<Manager> managers) {
this.managers = new ArrayList<>(managers);
}
@Override
public List<String> getAccountNumbers() {
return managers.stream().map(Manager::getSelfNumber).toList();
}
@Override
public List<Manager> getManagers() {
return managers;
}
@Override
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
addedHandlers.add(handler);
}
@Override
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
}
@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() {
}
}
private static String jsonRpcCall(int id, String method) {
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\"}";
}
private static String jsonRpcCall(int id, String method, String params) {
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}";
}
// --- Single-account mode tests ---
@Test
void callEventsNotSubscribedByDefault() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
// Send no subscribeCallEvents, just end the connection
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
// No listeners should have been added
assertEquals(0, manager.addCount.get(), "call events should not be auto-subscribed");
}
@Test
void subscribeCallEventsAddsListener() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
// null terminates the read loop
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
assertEquals(1, manager.addCount.get(), "subscribeCallEvents should add one listener");
// Cleanup in finally block should remove it
assertEquals(1, manager.removeCount.get(), "cleanup should remove the listener");
assertEquals(0, manager.listeners.size(), "no listeners should remain after cleanup");
}
@Test
void subscribeCallEventsCanBeCalledMultipleTimes() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "subscribeCallEvents"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
// The implementation allows multiple subscriptions, so two calls add two listeners
assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners");
}
@Test
void unsubscribeCallEventsRemovesListener() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
assertEquals(1, manager.addCount.get(), "should have subscribed once");
// removeCount: 1 from explicit unsubscribe. The finally block's unsubscribeAllCallEvents
// iterates an empty list so adds 0 more.
assertEquals(1, manager.removeCount.get(), "should have unsubscribed once");
assertEquals(0, manager.listeners.size());
}
@Test
void unsubscribeWithoutSubscribeIsNoOp() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "unsubscribeCallEvents"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
assertEquals(0, manager.addCount.get());
assertEquals(0, manager.removeCount.get());
}
// --- Multi-account mode tests ---
@Test
void multiAccountSubscribeCallEventsSubscribesAllManagers() {
var manager1 = new StubManager("+15551111111");
var manager2 = new StubManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1, manager2));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(1, manager1.addCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.addCount.get(), "manager2 should have one listener");
// Also registers an onManagerAdded handler for receive and one for call events
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
}
@Test
void multiAccountUnsubscribeCallEventsCleansUpAll() {
var manager1 = new StubManager("+15551111111");
var manager2 = new StubManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1, manager2));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(1, manager1.addCount.get());
assertEquals(1, manager2.addCount.get());
assertEquals(1, manager1.removeCount.get(), "manager1 listener should be removed");
assertEquals(1, manager2.removeCount.get(), "manager2 listener should be removed");
}
@Test
void multiAccountCallEventsNotSubscribedByDefault() {
var manager1 = new StubManager("+15551111111");
var multi = new StubMultiAccountManager(List.of(manager1));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(0, manager1.addCount.get(), "call events should not be auto-subscribed in multi mode");
}
}