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 application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.5" id("org.graalvm.buildtools.native") version "1.0.0"
} }
allprojects { allprojects {
@ -104,6 +104,14 @@ dependencies {
implementation(libs.micronaut.json.schema.generator) implementation(libs.micronaut.json.schema.generator)
} }
implementation(project(":libsignal-cli")) implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
} }
configurations { 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" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.32" 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" sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
hikari = "com.zaxxer:HikariCP:7.0.2" hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" } 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.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration; 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.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults; 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.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; 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.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; 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; 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 @Override
void close(); void close();
void addCallEventListener(CallEventListener listener);
void removeCallEventListener(CallEventListener listener);
interface ReceiveMessageHandler { interface ReceiveMessageHandler {
ReceiveMessageHandler EMPTY = (envelope, e) -> { ReceiveMessageHandler EMPTY = (envelope, e) -> {
@ -423,4 +470,9 @@ public interface Manager extends Closeable {
void handleMessage(MessageEnvelope envelope, Throwable e); 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 CiphertextMessage.WHISPER_TYPE;
} }
return switch (type) { return switch (type) {
case PREKEY_BUNDLE -> CiphertextMessage.PREKEY_TYPE; case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE;
case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE; case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE;
case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE; case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE;
default -> CiphertextMessage.WHISPER_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 AccountHelper accountHelper;
private AttachmentHelper attachmentHelper; private AttachmentHelper attachmentHelper;
private CallManager callManager;
private ContactHelper contactHelper; private ContactHelper contactHelper;
private GroupHelper groupHelper; private GroupHelper groupHelper;
private GroupV2Helper groupV2Helper; private GroupV2Helper groupV2Helper;
@ -92,6 +93,10 @@ public class Context implements AutoCloseable {
return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this)); return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this));
} }
public CallManager getCallManager() {
return getOrCreate(() -> callManager, () -> callManager = new CallManager(this));
}
public ContactHelper getContactHelper() { public ContactHelper getContactHelper() {
return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account)); return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account));
} }
@ -172,6 +177,9 @@ public class Context implements AutoCloseable {
@Override @Override
public void close() { public void close() {
if (callManager != null) {
callManager.close();
}
jobExecutor.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.SignalServicePniSignatureMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; 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.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
@ -401,9 +402,54 @@ public final class IncomingMessageHandler {
longTexts.putAll(syncResults.second()); longTexts.putAll(syncResults.second());
} }
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId);
}
return new Pair<>(actions, longTexts); 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( private boolean handlePniSignatureMessage(
final SignalServicePniSignatureMessage message, final SignalServicePniSignatureMessage message,
final SignalServiceAddress senderAddress final SignalServiceAddress senderAddress
@ -615,10 +661,6 @@ public final class IncomingMessageHandler {
final var aep = keysMessage.getAccountEntropyPool(); final var aep = keysMessage.getAccountEntropyPool();
account.setAccountEntropyPool(aep); account.setAccountEntropyPool(aep);
actions.add(SyncStorageDataAction.create()); 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) { } else if (keysMessage.getStorageService() != null) {
final var storageKey = keysMessage.getStorageService(); final var storageKey = keysMessage.getStorageService();
account.setStorageKey(storageKey); 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.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; 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.SentTranscriptMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
@ -309,6 +310,26 @@ public class SendHelper {
return result; 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( private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder, final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g, final GroupInfo g,

View File

@ -258,7 +258,6 @@ public class SyncHelper {
public SendMessageResult sendKeysMessage() { public SendMessageResult sendKeysMessage() {
var keysMessage = new KeysMessage(account.getOrCreateStorageKey(), var keysMessage = new KeysMessage(account.getOrCreateStorageKey(),
account.getOrCreatePinMasterKey(),
account.getOrCreateAccountEntropyPool(), account.getOrCreateAccountEntropyPool(),
account.getOrCreateMediaRootBackupKey()); account.getOrCreateMediaRootBackupKey());
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage)); 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.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration; 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.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TextStyle; 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.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; 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.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; 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.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
@ -701,9 +710,9 @@ public class ManagerImpl implements Manager {
results.put(recipient, results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); 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() 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()); results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
} }
} }
@ -843,7 +852,8 @@ public class ManagerImpl implements Manager {
messageBuilder.withBody(message.messageText()); messageBuilder.withBody(message.messageText());
} }
if (!message.attachments().isEmpty()) { 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()) { if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments); additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments); messageBuilder.withAttachments(additionalAttachments);
@ -949,12 +959,10 @@ public class ManagerImpl implements Manager {
var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
for (final var recipient : recipients) { for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid u) { if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) {
account.getMessageSendLogStore() account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid())); } else if (recipient instanceof RecipientIdentifier.Pni(var pni)) {
} else if (recipient instanceof RecipientIdentifier.Pni pni) { account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
} else if (recipient instanceof RecipientIdentifier.Single r) { } else if (recipient instanceof RecipientIdentifier.Single r) {
try { try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r); final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@ -965,8 +973,8 @@ public class ManagerImpl implements Manager {
} }
} catch (UnregisteredRecipientException ignored) { } catch (UnregisteredRecipientException ignored) {
} }
} else if (recipient instanceof RecipientIdentifier.Group r) { } else if (recipient instanceof RecipientIdentifier.Group(var groupId)) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId()); account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId);
} }
} }
return sendMessage(messageBuilder, recipients, false); return sendMessage(messageBuilder, recipients, false);
@ -1139,8 +1147,8 @@ public class ManagerImpl implements Manager {
results.put(recipient, results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); 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.getSyncHelper().sendMessageRequestResponse(type, group.groupId()); final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId);
results.put(recipient, List.of(toSendMessageResult(result))); results.put(recipient, List.of(toSendMessageResult(result)));
} }
} }
@ -1154,7 +1162,7 @@ public class ManagerImpl implements Manager {
final List<String> options, final List<String> options,
final Set<RecipientIdentifier> recipients, final Set<RecipientIdentifier> recipients,
final boolean notifySelf 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 pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate);
return sendMessage(messageBuilder, recipients, notifySelf); return sendMessage(messageBuilder, recipients, notifySelf);
@ -1186,7 +1194,7 @@ public class ManagerImpl implements Manager {
final long targetSentTimestamp, final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients, final Set<RecipientIdentifier> recipients,
final boolean notifySelf final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp); final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate);
return sendMessage(messageBuilder, recipients, notifySelf); 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 @Override
public InputStream retrieveAttachment(final String id) throws IOException { public InputStream retrieveAttachment(final String id) throws IOException {
return context.getAttachmentHelper().retrieveAttachment(id).getStream(); return context.getAttachmentHelper().retrieveAttachment(id).getStream();
@ -1761,6 +1779,132 @@ public class ManagerImpl implements Manager {
return streamDetails.getStream(); 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 @Override
public void close() { public void close() {
Thread thread; Thread thread;

View File

@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
ret.getAciIdentity(), ret.getAciIdentity(),
ret.getPniIdentity(), ret.getPniIdentity(),
profileKey, profileKey,
ret.getMasterKey(),
ret.getAccountEntropyPool(), ret.getAccountEntropyPool(),
ret.getMediaRootBackupKey()); 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.SignalSessionLock;
import org.whispersystems.signalservice.api.account.AccountApi; import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi; 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.cds.CdsApi;
import org.whispersystems.signalservice.api.certificate.CertificateApi; import org.whispersystems.signalservice.api.certificate.CertificateApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
@ -76,6 +77,7 @@ public class SignalDependencies {
private StorageServiceApi storageServiceApi; private StorageServiceApi storageServiceApi;
private CertificateApi certificateApi; private CertificateApi certificateApi;
private AttachmentApi attachmentApi; private AttachmentApi attachmentApi;
private CallingApi callingApi;
private MessageApi messageApi; private MessageApi messageApi;
private KeysApi keysApi; private KeysApi keysApi;
private GroupsV2Operations groupsV2Operations; private GroupsV2Operations groupsV2Operations;
@ -255,6 +257,13 @@ public class SignalDependencies {
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket())); () -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
} }
public CallingApi getCallingApi() {
return getOrCreate(() -> callingApi,
() -> callingApi = new CallingApi(getAuthenticatedSignalWebSocket(),
getUnauthenticatedSignalWebSocket(),
getPushServiceSocket()));
}
public MessageApi getMessageApi() { public MessageApi getMessageApi() {
return getOrCreate(() -> messageApi, return getOrCreate(() -> messageApi,
() -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(), () -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(),

View File

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

View File

@ -18,6 +18,7 @@ import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigInteger;
import java.net.Proxy; import java.net.Proxy;
import java.net.ProxySelector; import java.net.ProxySelector;
import java.net.URI; import java.net.URI;
@ -235,4 +236,11 @@ public class Utils {
return proxies.getFirst(); 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.ArrayList;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final Manager m; final Manager m;
@ -297,26 +299,32 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
} }
if (callMessage.answer().isPresent()) { if (callMessage.answer().isPresent()) {
var answerMessage = callMessage.answer().get(); 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()) { if (callMessage.busy().isPresent()) {
var busyMessage = callMessage.busy().get(); var busyMessage = callMessage.busy().get();
writer.println("Busy message: {}", busyMessage.id()); writer.println("Busy message: {}", callIdUnsigned(busyMessage.id()));
} }
if (callMessage.hangup().isPresent()) { if (callMessage.hangup().isPresent()) {
var hangupMessage = callMessage.hangup().get(); var hangupMessage = callMessage.hangup().get();
writer.println("Hangup message: {}", hangupMessage.id()); writer.println("Hangup message: {}", callIdUnsigned(hangupMessage.id()));
} }
if (!callMessage.iceUpdate().isEmpty()) { if (!callMessage.iceUpdate().isEmpty()) {
writer.println("Ice update messages:"); writer.println("Ice update messages:");
var iceUpdateMessages = callMessage.iceUpdate(); var iceUpdateMessages = callMessage.iceUpdate();
for (var iceUpdateMessage : iceUpdateMessages) { 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()) { if (callMessage.offer().isPresent()) {
var offerMessage = callMessage.offer().get(); 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()) { if (callMessage.opaque().isPresent()) {
final var opaqueMessage = callMessage.opaque().get(); 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<>(); private static final Map<String, SubparserAttacher> commandSubparserAttacher = new TreeMap<>();
static { static {
addCommand(new AcceptCallCommand());
addCommand(new AddDeviceCommand()); addCommand(new AddDeviceCommand());
addCommand(new BlockCommand()); addCommand(new BlockCommand());
addCommand(new DaemonCommand()); addCommand(new DaemonCommand());
addCommand(new DeleteLocalAccountDataCommand()); addCommand(new DeleteLocalAccountDataCommand());
addCommand(new FinishChangeNumberCommand()); addCommand(new FinishChangeNumberCommand());
addCommand(new FinishLinkCommand()); addCommand(new FinishLinkCommand());
addCommand(new HangupCallCommand());
addCommand(new GetAttachmentCommand()); addCommand(new GetAttachmentCommand());
addCommand(new GetAvatarCommand()); addCommand(new GetAvatarCommand());
addCommand(new GetStickerCommand()); addCommand(new GetStickerCommand());
addCommand(new GetUserStatusCommand()); addCommand(new GetUserStatusCommand());
addCommand(new AddStickerPackCommand()); addCommand(new AddStickerPackCommand());
addCommand(new JoinGroupCommand()); addCommand(new JoinGroupCommand());
addCommand(new ListCallsCommand());
addCommand(new JsonRpcDispatcherCommand()); addCommand(new JsonRpcDispatcherCommand());
addCommand(new LinkCommand()); addCommand(new LinkCommand());
addCommand(new ListAccountsCommand()); addCommand(new ListAccountsCommand());
@ -32,6 +35,7 @@ public class Commands {
addCommand(new ListStickerPacksCommand()); addCommand(new ListStickerPacksCommand());
addCommand(new QuitGroupCommand()); addCommand(new QuitGroupCommand());
addCommand(new ReceiveCommand()); addCommand(new ReceiveCommand());
addCommand(new RejectCallCommand());
addCommand(new RegisterCommand()); addCommand(new RegisterCommand());
addCommand(new RemoveContactCommand()); addCommand(new RemoveContactCommand());
addCommand(new RemoveDeviceCommand()); addCommand(new RemoveDeviceCommand());
@ -52,6 +56,7 @@ public class Commands {
addCommand(new SendTypingCommand()); addCommand(new SendTypingCommand());
addCommand(new SendUnpinMessageCommand()); addCommand(new SendUnpinMessageCommand());
addCommand(new SetPinCommand()); addCommand(new SetPinCommand());
addCommand(new StartCallCommand());
addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new SubmitRateLimitChallengeCommand());
addCommand(new StartChangeNumberCommand()); addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand()); 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.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Contact; 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.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults; 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.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TrustLevel; 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.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; 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 @Override
public void close() { public void close() {
synchronized (this) { 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 org.asamk.signal.manager.api.MessageEnvelope;
import java.math.BigInteger;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
@JsonSchema(title = "CallMessage") @JsonSchema(title = "CallMessage")
record JsonCallMessage( record JsonCallMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage, @JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage,
@ -25,38 +29,41 @@ record JsonCallMessage(
callMessage.iceUpdate().stream().map(IceUpdate::from).toList()); 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) { 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) { 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) { 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) { 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) { 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.JsonRpcSingleCommand;
import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.json.JsonCallEvent;
import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager; import org.asamk.signal.manager.MultiAccountManager;
@ -24,6 +25,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.nio.channels.ClosedChannelException; import java.nio.channels.ClosedChannelException;
import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -40,6 +42,7 @@ public class SignalJsonRpcDispatcherHandler {
private final boolean noReceiveOnStart; private final boolean noReceiveOnStart;
private final Map<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>(); 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; private SignalJsonRpcCommandHandler commandHandler;
public SignalJsonRpcDispatcherHandler( public SignalJsonRpcDispatcherHandler(
@ -61,6 +64,10 @@ public class SignalJsonRpcDispatcherHandler {
c.addOnManagerAddedHandler(m -> subscribeReceive(m, true)); c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
c.addOnManagerRemovedHandler(this::unsubscribeReceive); 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(); handleConnection();
} }
@ -78,6 +85,57 @@ public class SignalJsonRpcDispatcherHandler {
handleConnection(); 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 static final AtomicInteger nextSubscriptionId = new AtomicInteger(0);
private int subscribeReceive(final Manager manager, boolean internalSubscription) { private int subscribeReceive(final Manager manager, boolean internalSubscription) {
@ -86,7 +144,19 @@ public class SignalJsonRpcDispatcherHandler {
private int subscribeReceive(final List<Manager> managers, boolean internalSubscription) { private int subscribeReceive(final List<Manager> managers, boolean internalSubscription) {
final var subscriptionId = nextSubscriptionId.getAndIncrement(); final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var handlers = managers.stream().map(m -> { 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 -> { final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> {
ContainerNode<?> params; ContainerNode<?> params;
if (internalSubscription) { if (internalSubscription) {
@ -107,11 +177,7 @@ public class SignalJsonRpcDispatcherHandler {
} }
}); });
m.addReceiveHandler(receiveMessageHandler); m.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m, (Manager.ReceiveMessageHandler) receiveMessageHandler); return new Pair<>(m, receiveMessageHandler);
}).toList();
receiveHandlers.put(subscriptionId, handlers);
return subscriptionId;
} }
private boolean unsubscribeReceive(final int subscriptionId) { private boolean unsubscribeReceive(final int subscriptionId) {
@ -141,6 +207,7 @@ public class SignalJsonRpcDispatcherHandler {
} finally { } finally {
receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler)); receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler));
receiveHandlers.clear(); receiveHandlers.clear();
unsubscribeAllCallEvents();
} }
} }
@ -157,6 +224,12 @@ public class SignalJsonRpcDispatcherHandler {
if ("unsubscribeReceive".equals(method)) { if ("unsubscribeReceive".equals(method)) {
return new UnsubscribeReceiveCommand(); return new UnsubscribeReceiveCommand();
} }
if ("subscribeCallEvents".equals(method)) {
return new SubscribeCallEventsCommand();
}
if ("unsubscribeCallEvents".equals(method)) {
return new UnsubscribeCallEventsCommand();
}
return Commands.getCommand(method); 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" "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", "type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -1998,6 +2046,20 @@
"allDeclaredMethods": true, "allDeclaredMethods": true,
"allDeclaredConstructors": 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", "type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
"allDeclaredFields": true, "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", "type": "org.asamk.signal.commands.ListContactsCommand$JsonContact",
"allDeclaredFields": true, "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", "type": "org.asamk.signal.commands.StartLinkCommand$JsonLink",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -2292,6 +2451,43 @@
{ {
"type": "org.asamk.signal.json.JsonAttachment[]" "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", "type": "org.asamk.signal.json.JsonCallMessage",
"allDeclaredFields": true, "allDeclaredFields": true,
@ -7355,6 +7551,35 @@
"allDeclaredFields": true, "allDeclaredFields": true,
"allDeclaredMethods": 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", "type": "org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo",
"allDeclaredFields": true, "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", "type": "org.whispersystems.signalservice.internal.push.GetUsernameFromLinkResponseBody",
"allDeclaredFields": true, "allDeclaredFields": true,

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");
}
}