Add JSON-RPC commands for voice call control

Add startCall, acceptCall, hangupCall, rejectCall, and listCalls
commands for the JSON-RPC daemon interface. Register commands and
update GraalVM metadata for native image support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shaheen Gandhi 2026-02-11 14:24:46 -08:00
parent fafc5e3563
commit 32ada51c44
9 changed files with 571 additions and 1 deletions

View File

@ -91,6 +91,14 @@ dependencies {
implementation(libs.logback)
implementation(libs.zxing)
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
configurations {

View File

@ -0,0 +1,78 @@
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 {
final var callIdNumber = ns.get("call-id");
if (callIdNumber == null) {
throw new UserErrorException("No call ID given");
}
final long callId = ((Number) callIdNumber).longValue();
try {
var callInfo = m.acceptCall(callId);
switch (outputWriter) {
case PlainTextWriter writer -> {
writer.println("Call accepted:");
writer.println(" Call ID: {}", callInfo.callId());
writer.println(" State: {}", callInfo.state());
writer.println(" Input device: {}", callInfo.inputDeviceName());
writer.println(" Output device: {}", callInfo.outputDeviceName());
}
case JsonWriter writer -> writer.write(new JsonCallInfo(callInfo.callId(),
callInfo.state().name(),
callInfo.inputDeviceName(),
callInfo.outputDeviceName(),
"opus",
48000,
1,
20));
}
} catch (IOException e) {
throw new IOErrorException("Failed to accept call: " + e.getMessage(), e);
}
}
private record JsonCallInfo(
long callId,
String state,
String inputDeviceName,
String outputDeviceName,
String codec,
int sampleRate,
int channels,
int ptimeMs
) {}
}

View File

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

View File

@ -0,0 +1,56 @@
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 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 {
final var callIdNumber = ns.get("call-id");
if (callIdNumber == null) {
throw new UserErrorException("No call ID given");
}
final long callId = ((Number) callIdNumber).longValue();
try {
m.hangupCall(callId);
switch (outputWriter) {
case PlainTextWriter writer -> writer.println("Call {} hung up.", callId);
case JsonWriter writer -> writer.write(new JsonResult(callId, "hung_up"));
}
} catch (IOException e) {
throw new IOErrorException("Failed to hang up call: " + e.getMessage(), e);
}
}
private record JsonResult(long callId, String status) {}
}

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,56 @@
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 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 {
final var callIdNumber = ns.get("call-id");
if (callIdNumber == null) {
throw new UserErrorException("No call ID given");
}
final long callId = ((Number) callIdNumber).longValue();
try {
m.rejectCall(callId);
switch (outputWriter) {
case PlainTextWriter writer -> writer.println("Call {} rejected.", callId);
case JsonWriter writer -> writer.write(new JsonResult(callId, "rejected"));
}
} catch (IOException e) {
throw new IOErrorException("Failed to reject call: " + e.getMessage(), e);
}
}
private record JsonResult(long callId, String status) {}
}

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

@ -1947,6 +1947,40 @@
}
]
},
{
"type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "channels",
"parameterTypes": []
},
{
"name": "codec",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
},
{
"name": "sampleRate",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams",
"allDeclaredFields": true,
@ -1984,6 +2018,20 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.commands.HangupCallCommand$JsonResult",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "status",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
"allDeclaredFields": true,
@ -1994,6 +2042,39 @@
}
]
},
{
"type": "org.asamk.signal.commands.ListCallsCommand$JsonCall",
"allDeclaredFields": true,
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "number",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.ListCallsCommand$JsonCall[]"
},
{
"type": "org.asamk.signal.commands.ListContactsCommand$JsonContact",
"allDeclaredFields": true,
@ -2159,6 +2240,54 @@
}
]
},
{
"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": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
},
{
"name": "sampleRate",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.commands.StartLinkCommand$JsonLink",
"allDeclaredFields": true,
@ -9782,4 +9911,4 @@
"bundle": "net.sourceforge.argparse4j.internal.ArgumentParserImpl"
}
]
}
}

View File

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