Compare commits

...

28 Commits

Author SHA1 Message Date
Shaheen Gandhi
fd0bb1cbc4
Merge 9493381f57b28b56943ef174428023f09f29ae3c into 2885ffeee867203f5f21f65a9af2a080f92188ec 2026-03-06 00:16:28 +00:00
Shaheen Gandhi
9493381f57 Add call tunnel documentation
Add documentation about the architecture, protocol, and implementation of
signal-call-tunnel, the secure tunnel subprocess for voice calling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
32ada51c44 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>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
fafc5e3563 Add call state notification mechanism for JSON-RPC clients
Implement CallEventListener callback pattern that fires on every call
state transition (RINGING_INCOMING, RINGING_OUTGOING, CONNECTING,
CONNECTED, ENDED). The JSON-RPC layer auto-subscribes and pushes
callEvent notifications alongside receive notifications.

Changes:
- Manager.java: Add CallEventListener interface and methods
- ManagerImpl.java: Implement add/removeCallEventListener with cleanup
- DbusManagerImpl.java: Add stub implementation (not supported over DBus)
- JsonCallEvent.java: JSON notification record for call events
- SignalJsonRpcDispatcherHandler.java: Auto-subscribe call event listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
fa010d03cf Implement call signaling state machine and message routing
Add CallSignalingHelper for x25519 key generation and HKDF-based SRTP
key derivation. Add CallManager for tracking active calls, spawning
call tunnel subprocesses, and handling call lifecycle (offer, answer,
ICE candidates, hangup, busy). Wire call message routing in
IncomingMessageHandler and implement Manager call methods in ManagerImpl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
0ea4838e01 Add voice call API types, protobuf definitions, and build dependencies
Define call method interfaces in Manager, create API records (CallInfo,
CallOffer, TurnServer), and hand-coded protobuf parsers for RingRTC
signaling messages (ConnectionParametersV4, RtpDataMessage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:53 -08:00
AsamK
2885ffeee8 Prepare next release 2026-03-03 17:50:00 +01:00
moppman
6071291f16
Expose a chat's isArchived status in JSON output (#1957)
Closes #1955
2026-03-03 12:46:34 +01:00
Zachary Johnson
37b8a4a996 enforce poll choices are between 2 and 10 2026-03-03 08:37:10 +01:00
AsamK
af56a28b94 Fix client folder name 2026-03-01 10:30:02 +01:00
AsamK
dfc7e3b495 Bump version to 0.14.0 2026-03-01 10:14:45 +01:00
AsamK
e9114ae8fc Add signal-cli-client to release and create container 2026-03-01 10:14:45 +01:00
AsamK
f77a74d93f Fix optional name parameter in client link command 2026-03-01 10:14:45 +01:00
AsamK
5cda87ee0e Fix native access warning in native build 2026-03-01 10:14:45 +01:00
AsamK
7384407823 Update man page 2026-03-01 09:50:22 +01:00
AsamK
7fa56a37fd Update CI actions 2026-03-01 09:29:19 +01:00
AsamK
8fcd953ece Always download long text attachments and use them as message body
Fixes #1901
2026-03-01 09:29:19 +01:00
AsamK
775236efc3 Update tests 2026-02-28 14:05:55 +01:00
AsamK
4b8dec26a9 Downgrade jackson library 2026-02-28 13:45:56 +01:00
AsamK
d94e05c38c Update test file 2026-02-28 13:45:56 +01:00
AsamK
1bbf98fac0 Update dependencies 2026-02-28 13:45:30 +01:00
AsamK
c70515035f Add missing flags to jsonrpc client 2026-02-28 13:45:30 +01:00
AsamK
a9d235b7f1 Add new commands to jsonrpc client 2026-02-28 13:45:30 +01:00
AsamK
92ded3fdf2 Fix incorrect cli definition 2026-02-28 13:45:30 +01:00
AsamK
aa1ed9e233 Add support for sending adminDelete messages 2026-02-28 11:59:31 +01:00
AsamK
3b6c199b1d Add pinMessage and unpinMessage commands
Closes #1923
2026-02-28 11:50:44 +01:00
AsamK
6d22ceef24 Support receiving admin delete messages 2026-02-28 11:30:33 +01:00
AsamK
54ff59737e Update libsignal-service-java
Fixes #1937
2026-02-28 11:14:53 +01:00
52 changed files with 4546 additions and 416 deletions

View File

@ -19,14 +19,14 @@ jobs:
java: [ '25' ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
@ -45,7 +45,7 @@ jobs:
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
@ -65,7 +65,7 @@ jobs:
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
@ -82,13 +82,13 @@ jobs:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |

View File

@ -21,13 +21,13 @@ jobs:
steps:
- name: Setup Java JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 25
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@ -35,7 +35,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
@ -43,7 +43,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4

View File

@ -35,7 +35,7 @@ jobs:
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -79,6 +79,14 @@ jobs:
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
- name: Compress client app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-client-ubuntu/signal-cli-client
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-client.tar.gz -C signal-cli-client-ubuntu signal-cli-client
rm -rf signal-cli-client-ubuntu/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
@ -138,6 +146,16 @@ jobs:
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux client archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows archive
# uses: actions/upload-release-asset@v1
# env:
@ -166,9 +184,9 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -216,9 +234,9 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -255,3 +273,51 @@ jobs:
- name: Echo outputs
run: |
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
mkdir -p client/target/release/
chmod +x ./signal-cli-client-ubuntu/signal-cli-client
mv ./signal-cli-client-ubuntu/signal-cli-client client/target/release/
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ steps.cli_ver.outputs.version }}-client
containerfiles:
./client.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v2
id: push
with:
image: ${{ steps.build_image.outputs.image }}
tags: ${{ steps.build_image.outputs.tags }}
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Echo outputs
run: |
echo "${{ toJSON(steps.push.outputs) }}"

View File

@ -2,8 +2,12 @@
## [Unreleased]
## [0.14.0] - 2026-03-01
**Attention**: Now requires Java 25
Requires libsignal-client version 0.87.4.
### Breaking changes
- Remove isRegistered method without parameters from Signal dbus interface, which always returned `true`
@ -13,8 +17,22 @@
### Added
- The `link` command now prints a QR code in the terminal. (Thanks @karel1980)
- Add --ignore-avatars flag to prevent downloading avatars
- Add --ignore-stickers flag to prevent downloading sticker packs
- Add `--no-urgent` flag to `send` command to send messages that don't trigger a push notification. (Thanks @kaikozlov)
- Add `sendPinMessage`/`sendUnpinMessage` commands for pinning messages
### Improved
- Improved behavior for unregistered contacts
- Profiles are refreshed when using the listContacts command
- For long text messages the text attachment is used instead of the truncated body
### Fixed
- Adapted to new binary aci/pni formats in the Signal protocol
- Group invites should now work when the user was invited via phone number (PNI only)
## [0.13.24] - 2026-02-05

View File

@ -8,7 +8,7 @@ plugins {
allprojects {
group = "org.asamk"
version = "0.14.0-SNAPSHOT"
version = "0.14.1-SNAPSHOT"
}
java {
@ -33,6 +33,7 @@ graalvmNative {
buildArgs.add("-Dfile.encoding=UTF-8")
buildArgs.add("-J-Dfile.encoding=UTF-8")
buildArgs.add("-march=compatibility")
buildArgs.add("--enable-native-access=ALL-UNNAMED")
resources.autodetect()
if (System.getenv("GRAALVM_HOME") == null) {
toolchainDetection.set(true)
@ -90,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 {

11
client.Containerfile Normal file
View File

@ -0,0 +1,11 @@
FROM docker.io/debian:testing-slim
LABEL org.opencontainers.image.source=https://github.com/AsamK/signal-cli
LABEL org.opencontainers.image.description="signal-cli provides an unofficial commandline, dbus and JSON-RPC interface for the Signal messenger."
LABEL org.opencontainers.image.licenses=GPL-3.0-only
RUN useradd signal-cli --system
ADD client/target/release/signal-cli-client /usr/bin/signal-cli-client
USER signal-cli
ENTRYPOINT ["/usr/bin/signal-cli-client"]

572
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,9 @@ pub struct Cli {
#[arg(short = 'a', long)]
pub account: Option<String>,
#[arg(long)]
pub output: Option<String>,
/// TCP host and port of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_http")]
pub json_rpc_tcp: Option<Option<SocketAddr>>,
@ -94,7 +97,7 @@ pub enum CliCommands {
},
Link {
#[arg(short = 'n', long)]
name: String,
name: Option<String>,
},
ListAccounts,
ListContacts {
@ -105,6 +108,10 @@ pub enum CliCommands {
blocked: Option<bool>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
detailed: bool,
#[arg(long)]
internal: bool,
},
ListDevices,
ListGroups {
@ -135,6 +142,8 @@ pub enum CliCommands {
voice: bool,
#[arg(long)]
captcha: Option<String>,
#[arg(long)]
reregister: bool,
},
RemoveContact {
recipient: String,
@ -167,15 +176,24 @@ pub enum CliCommands {
#[arg(short = 'g', long)]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long)]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
end_session: bool,
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
message_from_stdin: bool,
#[arg(short = 'a', long)]
attachment: Vec<String>,
@ -229,6 +247,25 @@ pub enum CliCommands {
#[arg(long)]
edit_timestamp: Option<u64>,
#[arg(long = "no-urgent")]
no_urgent: bool,
},
SendAdminDelete {
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long)]
story: bool,
#[arg(long)]
notify_self: bool,
},
SendContacts,
SendPaymentNotification {
@ -240,15 +277,117 @@ pub enum CliCommands {
#[arg(long)]
note: String,
},
SendPinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(short = 'd', long = "pin-duration")]
pin_duration: Option<i32>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendPollCreate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'q', long = "question")]
question: String,
#[arg(short = 'o', long = "option")]
option: Vec<String>,
#[arg(long = "no-multi")]
no_multi: bool,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollTerminate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollVote {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-author")]
poll_author: Option<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(short = 'o', long = "option")]
option: Vec<i32>,
#[arg(long = "vote-count")]
vote_count: i32,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendReaction {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
emoji: String,
@ -267,6 +406,9 @@ pub enum CliCommands {
SendReceipt {
recipient: String,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: Vec<u64>,
@ -283,12 +425,37 @@ pub enum CliCommands {
#[arg(short = 's', long)]
stop: bool,
},
SendUnpinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendMessageRequestResponse {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(long)]
r#type: MessageRequestResponseType,
},
SetPin {
@ -334,6 +501,10 @@ pub enum CliCommands {
discoverable_by_number: Option<bool>,
#[arg(long = "number-sharing")]
number_sharing: Option<bool>,
#[arg(short = 'u', long = "username")]
username: Option<String>,
#[arg(long = "delete-username")]
delete_username: bool,
},
UpdateConfiguration {
#[arg(long = "read-receipts")]
@ -356,6 +527,28 @@ pub enum CliCommands {
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(long = "given-name")]
given_name: Option<String>,
#[arg(long = "family-name")]
family_name: Option<String>,
#[arg(long = "nick-given-name")]
nick_given_name: Option<String>,
#[arg(long = "nick-family-name")]
nick_family_name: Option<String>,
#[arg(long)]
note: Option<String>,
},
UpdateDevice {
#[arg(short = 'd', long = "device-id")]
device_id: u32,
#[arg(short = 'n', long = "device-name")]
device_name: String,
},
UpdateGroup {
#[arg(short = 'g', long = "group-id")]

View File

@ -90,7 +90,7 @@ pub trait Rpc {
fn finish_link(
&self,
#[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: String,
#[allow(non_snake_case)] deviceName: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)]
@ -104,6 +104,8 @@ pub trait Rpc {
#[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>,
name: Option<String>,
detailed: bool,
internal: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)]
@ -141,6 +143,7 @@ pub trait Rpc {
account: Option<String>,
voice: bool,
captcha: Option<String>,
reregister: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)]
@ -179,32 +182,116 @@ pub trait Rpc {
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
usernames: Vec<String>,
#[allow(non_snake_case)] notifySelf: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
#[allow(non_snake_case)] viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
#[allow(non_snake_case)] textStyle: Vec<String>,
#[allow(non_snake_case)] quoteTimestamp: Option<u64>,
#[allow(non_snake_case)] quoteAuthor: Option<String>,
#[allow(non_snake_case)] quoteMessage: Option<String>,
#[allow(non_snake_case)] quoteMention: Vec<String>,
#[allow(non_snake_case)] quoteTextStyle: Vec<String>,
#[allow(non_snake_case)] quoteAttachment: Vec<String>,
#[allow(non_snake_case)] previewUrl: Option<String>,
#[allow(non_snake_case)] previewTitle: Option<String>,
#[allow(non_snake_case)] previewDescription: Option<String>,
#[allow(non_snake_case)] previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
#[allow(non_snake_case)] storyTimestamp: Option<u64>,
#[allow(non_snake_case)] storyAuthor: Option<String>,
#[allow(non_snake_case)] editTimestamp: Option<u64>,
#[allow(non_snake_case)] noUrgent: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendAdminDelete", param_kind = map)]
fn send_admin_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
story: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPinMessage", param_kind = map)]
fn send_pin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] pinDuration: Option<i32>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollCreate", param_kind = map)]
fn send_poll_create(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
question: String,
option: Vec<String>,
#[allow(non_snake_case)] noMulti: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollVote", param_kind = map)]
fn send_poll_vote(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollAuthor: Option<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
option: Vec<i32>,
#[allow(non_snake_case)] voteCount: i32,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollTerminate", param_kind = map)]
fn send_poll_terminate(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendUnpinMessage", param_kind = map)]
fn send_unpin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification(
&self,
@ -220,7 +307,9 @@ pub trait Rpc {
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
emoji: String,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
@ -233,6 +322,7 @@ pub trait Rpc {
&self,
account: Option<String>,
recipient: String,
usernames: Vec<String>,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
@ -314,6 +404,8 @@ pub trait Rpc {
unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
username: Option<String>,
deleteUsername: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)]
@ -333,6 +425,19 @@ pub trait Rpc {
recipient: String,
name: Option<String>,
expiration: Option<u32>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
#[allow(non_snake_case)] nickGivenName: Option<String>,
#[allow(non_snake_case)] nickFamilyName: Option<String>,
note: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateDevice", param_kind = map)]
fn update_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]

View File

@ -84,9 +84,19 @@ async fn handle_command(
all_recipients,
blocked,
name,
detailed,
internal,
} => {
client
.list_contacts(cli.account, recipient, all_recipients, blocked, name)
.list_contacts(
cli.account,
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
)
.await
}
CliCommands::ListDevices => client.list_devices(cli.account).await,
@ -105,8 +115,14 @@ async fn handle_command(
.quit_group(cli.account, group_id, delete, admin)
.await
}
CliCommands::Register { voice, captcha } => {
client.register(cli.account, voice, captcha).await
CliCommands::Register {
voice,
captcha,
reregister,
} => {
client
.register(cli.account, voice, captcha, reregister)
.await
}
CliCommands::RemoveContact {
recipient,
@ -140,9 +156,12 @@ async fn handle_command(
CliCommands::Send {
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
message,
message_from_stdin,
attachment,
view_once,
mention,
@ -161,15 +180,22 @@ async fn handle_command(
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
} => {
client
.send(
cli.account,
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
message.unwrap_or_default(),
if message_from_stdin {
std::io::read_to_string(std::io::stdin()).unwrap()
} else {
message.unwrap_or_default()
},
attachment,
view_once,
mention,
@ -188,10 +214,29 @@ async fn handle_command(
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
)
.await
}
CliCommands::SendContacts => client.send_contacts(cli.account).await,
CliCommands::SendAdminDelete {
group_id,
target_author,
target_timestamp,
story,
notify_self,
} => {
client
.send_admin_delete(
cli.account,
group_id,
target_author,
target_timestamp,
story,
notify_self,
)
.await
}
CliCommands::SendPaymentNotification {
recipient,
receipt,
@ -201,10 +246,108 @@ async fn handle_command(
.send_payment_notification(cli.account, recipient, receipt, note)
.await
}
CliCommands::SendPinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
} => {
client
.send_pin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SendPollCreate {
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
} => {
client
.send_poll_create(
cli.account,
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollTerminate {
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
} => {
client
.send_poll_terminate(
cli.account,
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollVote {
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
} => {
client
.send_poll_vote(
cli.account,
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendReaction {
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
@ -216,7 +359,9 @@ async fn handle_command(
cli.account,
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
@ -227,6 +372,7 @@ async fn handle_command(
}
CliCommands::SendReceipt {
recipient,
username,
target_timestamp,
r#type,
} => {
@ -234,6 +380,7 @@ async fn handle_command(
.send_receipt(
cli.account,
recipient,
username,
target_timestamp,
match r#type {
cli::ReceiptType::Read => "read".to_owned(),
@ -252,6 +399,30 @@ async fn handle_command(
.send_typing(cli.account, recipient, group_id, stop)
.await
}
CliCommands::SendUnpinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
} => {
client
.send_unpin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SetPin { pin } => client.set_pin(cli.account, pin).await,
CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
client
@ -284,6 +455,8 @@ async fn handle_command(
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
} => {
client
.update_account(
@ -292,6 +465,8 @@ async fn handle_command(
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
)
.await
}
@ -315,9 +490,32 @@ async fn handle_command(
recipient,
expiration,
name,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
} => {
client
.update_contact(cli.account, recipient, name, expiration)
.update_contact(
cli.account,
recipient,
name,
expiration,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
)
.await
}
CliCommands::UpdateDevice {
device_id,
device_name,
} => {
client
.update_device(cli.account, device_id, device_name)
.await
}
CliCommands::UpdateGroup {

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.0" date="2026-03-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url>
</release>
<release version="0.13.24" date="2026-02-05">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.24</url>
</release>

372
docs/CALL_TUNNEL.md Normal file
View File

@ -0,0 +1,372 @@
# 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 it over a Unix domain socket using
newline-delimited JSON messages, relaying signaling between the tunnel and the
Signal protocol.
```
signal-cli signal-call-tunnel
| |
|-- spawn (config on stdin) --------->|
| |
|<======= ctrl.sock (JSON) ==========>|
| signaling relay | WebRTC
| | audio I/O
| |
```
Each call gets its own tunnel process and control socket inside a temporary
directory (`/tmp/sc-<random>/`). When the call ends, signal-cli kills the
process and deletes the directory.
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. Creates a temporary directory `/tmp/sc-<random>/` (mode `0700`)
2. Generates a random 32-byte auth token
3. Spawns `signal-call-tunnel` with config JSON on stdin
4. Connects to the control socket (retries up to 50x at 200 ms intervals)
5. Authenticates with the auth token
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`
3. `signal-call-tunnel` on `PATH`
### Config JSON
Written to the tunnel's stdin before it starts:
```json
{
"call_id": 12345,
"is_outgoing": true,
"control_socket_path": "/tmp/sc-a1b2c3/ctrl.sock",
"control_token": "dG9rZW4...",
"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 |
| `control_socket_path` | string | Path where the tunnel creates its control socket |
| `control_token` | string | Base64-encoded 32-byte auth token |
| `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 Socket Protocol
Unix SOCK_STREAM at `ctrl.sock`. Newline-delimited JSON messages.
### Authentication
The first message from signal-cli **must** be an auth message. The token is
a random 32-byte value generated per call and passed in the startup config.
The tunnel performs constant-time comparison.
```json
{"type":"auth","token":"<base64-encoded token>"}
```
### signal-cli -> Tunnel
| Type | When | Fields |
|------|------|--------|
| `auth` | First message | `token` |
| `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
| 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 on stdin) |
| | initialize
| | bind ctrl.sock
| |
|-- connect to ctrl.sock -------------->|
| (retries: 50x @ 200ms) |
|<-------- ready -----------------------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
|-- auth ------------------------------>|
| {"type":"auth","token":"<b64>"} |
| | constant-time token verify
| |
```
---
## Call Flows
### Outgoing call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- auth ----------------->| |
|-- 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 ----------------| |
|-- auth ----------------->| |
|-- 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.
It never touches the control socket directly.
```
JSON-RPC Client signal-cli daemon
| |
|-- 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
| |
|<-- 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) |
```
---
## 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. Creates a temp directory and generates a random auth token
2. Spawns `signal-call-tunnel` with config JSON on stdin
3. Connects to the control socket (retries up to 50x at 200 ms intervals),
authenticates, and relays signaling between the tunnel and the Signal protocol
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, kills the process, deletes the control socket
---
## 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.
---
## File Layout
```
/tmp/sc-<random>/
ctrl.sock control socket (signal-cli <-> tunnel)
```
The control socket is created with mode `0700` on the parent directory. The
directory and its contents are deleted when the call ends.

View File

@ -10,9 +10,9 @@ dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.29"
logback = "ch.qos.logback:logback-classic:1.5.32"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_138"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_140"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }

View File

@ -37,7 +37,11 @@ dependencies {
}
tasks.named<Test>("test") {
useJUnitPlatform()
useJUnitPlatform {
if (!project.hasProperty("includeIntegration")) {
excludeTags("integration")
}
}
}
configurations {

View File

@ -64,6 +64,10 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.TurnServer;
public interface Manager extends Closeable {
static boolean isValidNumber(final String e164Number, final String countryCode) {
@ -224,6 +228,31 @@ public interface Manager extends Closeable {
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendAdminDelete(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier.Group> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPinMessage(
int pinDuration,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPaymentNotificationMessage(
byte[] receipt,
String note,
@ -388,9 +417,37 @@ public interface Manager extends Closeable {
InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException;
// --- Voice call methods ---
CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
CallInfo acceptCall(long callId) throws IOException;
void hangupCall(long callId) throws IOException;
void rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answerOpaque) throws IOException, UnregisteredRecipientException;
void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List<byte[]> iceCandidates) throws IOException, UnregisteredRecipientException;
void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;
List<TurnServer> getTurnServerInfo() throws IOException;
@Override
void close();
void addCallEventListener(CallEventListener listener);
void removeCallEventListener(CallEventListener listener);
interface ReceiveMessageHandler {
ReceiveMessageHandler EMPTY = (envelope, e) -> {
@ -398,4 +455,9 @@ public interface Manager extends Closeable {
void handleMessage(MessageEnvelope envelope, Throwable e);
}
interface CallEventListener {
void handleCallEvent(CallInfo callInfo, String reason);
}
}

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

@ -3,6 +3,7 @@ package org.asamk.signal.manager.api;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -122,15 +124,28 @@ public record MessageEnvelope(
List<Preview> previews,
List<TextStyle> textStyles,
Optional<PinMessage> pinMessage,
Optional<UnpinMessage> unpinMessage
Optional<UnpinMessage> unpinMessage,
Optional<AdminDelete> adminDelete
) {
static Data from(
final SignalServiceDataMessage dataMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
var body = dataMessage.getBody();
if (dataMessage.getAttachments().isPresent()) {
for (final var attachment : dataMessage.getAttachments().get()) {
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
final var longBody = longTexts.get(attachment.asPointer().getRemoteId().toString());
if (longBody != null) {
body = Optional.of(longBody);
}
}
}
}
return new Data(dataMessage.getTimestamp(),
dataMessage.getGroupContext().map(GroupContext::from),
dataMessage.getStoryContext()
@ -138,7 +153,7 @@ public record MessageEnvelope(
recipientResolver,
addressResolver)),
dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from),
dataMessage.getBody(),
body,
dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(),
@ -173,8 +188,8 @@ public record MessageEnvelope(
.map(a -> a.stream().filter(r -> r.style != null).map(TextStyle::from).toList())
.orElse(List.of()),
dataMessage.getPinnedMessage().map(p -> PinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getUnpinnedMessage()
.map(p -> UnpinMessage.from(p, recipientResolver, addressResolver)));
dataMessage.getUnpinnedMessage().map(p -> UnpinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getAdminDelete().map(p -> AdminDelete.from(p, recipientResolver, addressResolver)));
}
public record GroupContext(GroupId groupId, boolean isGroupUpdate, int revision) {
@ -602,18 +617,35 @@ public record MessageEnvelope(
}
}
public record AdminDelete(RecipientAddress targetAuthor, long targetSentTimestamp) {
static AdminDelete from(
SignalServiceDataMessage.AdminDelete adminDelete,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new AdminDelete(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
adminDelete.getTargetAuthor())).toApiRecipientAddress(), adminDelete.getTargetSentTimestamp());
}
}
}
public record Edit(long targetSentTimestamp, Data dataMessage) {
public static Edit from(
final SignalServiceEditMessage editMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
return new Edit(editMessage.getTargetSentTimestamp(),
Data.from(editMessage.getDataMessage(), recipientResolver, addressResolver, fileProvider));
Data.from(editMessage.getDataMessage(),
longTexts,
recipientResolver,
addressResolver,
fileProvider));
}
}
@ -630,12 +662,13 @@ public record MessageEnvelope(
public static Sync from(
final SignalServiceSyncMessage syncMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
return new Sync(syncMessage.getSent()
.map(s -> Sent.from(s, recipientResolver, addressResolver, fileProvider)),
.map(s -> Sent.from(s, longTexts, recipientResolver, addressResolver, fileProvider)),
syncMessage.getBlockedList().map(b -> Blocked.from(b, recipientResolver, addressResolver)),
syncMessage.getRead()
.map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList())
@ -664,6 +697,7 @@ public record MessageEnvelope(
static Sent from(
SentTranscriptMessage sentMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
@ -679,9 +713,17 @@ public record MessageEnvelope(
.toApiRecipientAddress())
.collect(Collectors.toSet()),
sentMessage.getDataMessage()
.map(message -> Data.from(message, recipientResolver, addressResolver, fileProvider)),
.map(message -> Data.from(message,
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getEditMessage()
.map(message -> Edit.from(message, recipientResolver, addressResolver, fileProvider)),
.map(message -> Edit.from(message,
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getStoryMessage().map(s -> Story.from(s, fileProvider)));
}
}
@ -980,6 +1022,7 @@ public record MessageEnvelope(
public static MessageEnvelope from(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider,
@ -1010,9 +1053,15 @@ public record MessageEnvelope(
receipt = content.getReceiptMessage().map(Receipt::from);
typing = content.getTypingMessage().map(Typing::from);
data = content.getDataMessage()
.map(dataMessage -> Data.from(dataMessage, recipientResolver, addressResolver, fileProvider));
edit = content.getEditMessage().map(s -> Edit.from(s, recipientResolver, addressResolver, fileProvider));
sync = content.getSyncMessage().map(s -> Sync.from(s, recipientResolver, addressResolver, fileProvider));
.map(dataMessage -> Data.from(dataMessage,
longTexts,
recipientResolver,
addressResolver,
fileProvider));
edit = content.getEditMessage()
.map(s -> Edit.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
sync = content.getSyncMessage()
.map(s -> Sync.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
call = content.getCallMessage().map(Call::from);
story = content.getStoryMessage().map(s -> Story.from(s, fileProvider));
} else {

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,858 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.Channels;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.util.ArrayList;
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;
/**
* 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();
for (var listener : callEventListeners) {
try {
listener.handleCallEvent(callInfo, reason);
} catch (Throwable e) {
logger.warn("Call event listener failed, ignoring", e);
}
}
}
public CallInfo startOutgoingCall(
final RecipientIdentifier.Single recipient
) throws IOException, UnregisteredRecipientException {
var callId = generateCallId();
var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
var recipientAddress = context.getRecipientHelper()
.resolveSignalServiceAddress(recipientId)
.getServiceId();
var recipientApiAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress();
// Create per-call socket directory
var callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-");
Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------"));
var controlSocketPath = callDir.resolve("ctrl.sock").toString();
var state = new CallState(callId,
CallInfo.State.RINGING_OUTGOING,
recipientApiAddress,
recipient,
true,
controlSocketPath,
callDir);
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 peerIdStr = recipientAddress.toString();
sendControlMessage(state, "{\"type\":\"createOutgoingCall\",\"callId\":" + callIdJson(callId)
+ ",\"peerId\":\"" + escapeJson(peerIdStr) + "\"}");
sendProceed(state, callId, turnServers);
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.info("Started outgoing call {} to {}", callId, recipient);
return state.toCallInfo();
}
public CallInfo acceptIncomingCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
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.info("Accepted incoming call {}", callId);
return state.toCallInfo();
}
public void hangupCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
endCall(callId, "local_hangup");
}
public void rejectCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callId);
}
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy(
busyMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send busy message for call {}", callId, e);
}
endCall(callId, "rejected");
}
public List<CallInfo> listActiveCalls() {
return activeCalls.values().stream().map(CallState::toCallInfo).toList();
}
public List<TurnServer> getTurnServers() throws IOException {
try {
var result = dependencies.getCallingApi().getTurnServerInfo();
var turnServerList = result.successOrThrow();
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 org.asamk.signal.manager.storage.recipients.RecipientId senderId,
final long callId,
final MessageEnvelope.Call.Offer.Type type,
final byte[] opaque
) {
var senderAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(senderId)
.toApiRecipientAddress();
RecipientIdentifier.Single senderIdentifier;
if (senderAddress.number().isPresent()) {
senderIdentifier = new RecipientIdentifier.Number(senderAddress.number().get());
} else if (senderAddress.uuid().isPresent()) {
senderIdentifier = new RecipientIdentifier.Uuid(senderAddress.uuid().get());
} else {
logger.warn("Cannot identify sender for call {}", callId);
return;
}
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
Path callDir;
try {
callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-");
Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------"));
} catch (IOException e) {
logger.warn("Failed to create socket directory for incoming call {}", callId, e);
return;
}
var controlSocketPath = callDir.resolve("ctrl.sock").toString();
var state = new CallState(callId,
CallInfo.State.RINGING_INCOMING,
senderAddress,
senderIdentifier,
false,
controlSocketPath,
callDir);
state.rawOfferOpaque = opaque;
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().serialize());
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 {}", callId, e);
turnServers = List.of();
}
// Send receivedOffer to subprocess
var opaqueB64 = java.util.Base64.getEncoder().encodeToString(opaque);
var senderIdKeyB64 = java.util.Base64.getEncoder().encodeToString(remoteIdentityKey);
var receiverIdKeyB64 = java.util.Base64.getEncoder().encodeToString(localIdentityKey);
var peerIdStr = senderAddress.toString();
sendControlMessage(state, "{\"type\":\"receivedOffer\",\"callId\":" + callIdJson(callId)
+ ",\"peerId\":\"" + escapeJson(peerIdStr) + "\""
+ ",\"senderDeviceId\":1"
+ ",\"opaque\":\"" + opaqueB64 + "\""
+ ",\"age\":0"
+ ",\"senderIdentityKey\":\"" + senderIdKeyB64 + "\""
+ ",\"receiverIdentityKey\":\"" + receiverIdKeyB64 + "\""
+ "}");
// 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.info("Incoming call {} from {}", callId, senderAddress);
}
public void handleIncomingAnswer(final long callId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.warn("Received answer for unknown call {}", callId);
return;
}
// Get identity keys
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Forward raw opaque to subprocess
var opaqueB64 = java.util.Base64.getEncoder().encodeToString(opaque);
var senderIdKeyB64 = java.util.Base64.getEncoder().encodeToString(remoteIdentityKey);
var receiverIdKeyB64 = java.util.Base64.getEncoder().encodeToString(localIdentityKey);
sendControlMessage(state, "{\"type\":\"receivedAnswer\""
+ ",\"opaque\":\"" + opaqueB64 + "\""
+ ",\"senderDeviceId\":1"
+ ",\"senderIdentityKey\":\"" + senderIdKeyB64 + "\""
+ ",\"receiverIdentityKey\":\"" + receiverIdKeyB64 + "\""
+ "}");
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.info("Received answer for call {}", callId);
}
public void handleIncomingIceCandidate(final long callId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.debug("Received ICE candidate for unknown call {}", callId);
return;
}
// Forward to subprocess as receivedIce
var b64 = java.util.Base64.getEncoder().encodeToString(opaque);
sendControlMessage(state, "{\"type\":\"receivedIce\",\"candidates\":[\"" + b64 + "\"]}");
logger.debug("Forwarded ICE candidate to tunnel for call {}", callId);
}
public void handleIncomingHangup(final long callId) {
endCall(callId, "remote_hangup");
}
public void handleIncomingBusy(final long callId) {
endCall(callId, "remote_busy");
}
// --- Internal helpers ---
private void sendControlMessage(CallState state, String json) {
if (state.controlWriter == null) {
logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json);
state.pendingControlMessages.add(json);
return;
}
state.controlWriter.println(json);
}
private void sendProceed(CallState state, long callId, List<TurnServer> turnServers) {
var sb = new StringBuilder();
sb.append("{\"type\":\"proceed\",\"callId\":").append(callIdJson(callId));
sb.append(",\"hideIp\":false");
sb.append(",\"iceServers\":[");
for (int i = 0; i < turnServers.size(); i++) {
if (i > 0) sb.append(",");
var ts = turnServers.get(i);
sb.append("{\"username\":\"").append(escapeJson(ts.username())).append("\"");
sb.append(",\"password\":\"").append(escapeJson(ts.password())).append("\"");
sb.append(",\"urls\":[");
for (int j = 0; j < ts.urls().size(); j++) {
if (j > 0) sb.append(",");
sb.append("\"").append(escapeJson(ts.urls().get(j))).append("\"");
}
sb.append("]}");
}
sb.append("]}");
sendControlMessage(state, sb.toString());
}
private void spawnMediaTunnel(CallState state) {
try {
var command = new ArrayList<>(List.of(findTunnelBinary()));
// Config is sent via stdin; no --host-audio by default
var processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
var process = processBuilder.start();
// Write config JSON to stdin
var config = buildConfig(state);
try (var stdin = process.getOutputStream()) {
stdin.write(config.getBytes(StandardCharsets.UTF_8));
stdin.flush();
}
state.tunnelProcess = process;
// Drain subprocess stdout/stderr to prevent pipe buffer deadlock
Thread.ofVirtual().name("tunnel-output-" + state.callId).start(() -> {
try (var reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logger.debug("[tunnel-{}] {}", state.callId, line);
}
} catch (IOException ignored) {
}
});
// Connect to control socket in background
Thread.ofVirtual().name("control-connect-" + state.callId).start(() -> {
connectToControlSocket(state);
});
// Monitor process exit
process.onExit().thenAcceptAsync(p -> {
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue());
if (activeCalls.containsKey(state.callId)) {
endCall(state.callId, "tunnel_exit");
}
});
logger.info("Spawned signal-call-tunnel for call {}", state.callId);
} catch (Exception e) {
logger.error("Failed to spawn tunnel for call {}", 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
var installDir = System.getProperty("signal.cli.install.dir");
if (installDir != null) {
var binPath = Path.of(installDir, "bin", "signal-call-tunnel");
if (Files.isExecutable(binPath)) {
return binPath.toString();
}
}
// Fall back to PATH
return "signal-call-tunnel";
}
private String buildConfig(CallState state) {
// Generate control channel authentication token
var tokenBytes = new byte[32];
new SecureRandom().nextBytes(tokenBytes);
state.controlToken = java.util.Base64.getEncoder().encodeToString(tokenBytes);
var sb = new StringBuilder();
sb.append("{");
sb.append("\"call_id\":").append(callIdJson(state.callId));
sb.append(",\"is_outgoing\":").append(state.isOutgoing);
sb.append(",\"control_socket_path\":\"").append(escapeJson(state.controlSocketPath)).append("\"");
sb.append(",\"control_token\":\"").append(state.controlToken).append("\"");
sb.append(",\"local_device_id\":1");
sb.append("}");
return sb.toString();
}
private void connectToControlSocket(CallState state) {
var socketPath = Path.of(state.controlSocketPath);
var addr = UnixDomainSocketAddress.of(socketPath);
for (int attempt = 0; attempt < 50; attempt++) {
try {
Thread.sleep(200);
if (!Files.exists(socketPath)) continue;
var channel = SocketChannel.open(StandardProtocolFamily.UNIX);
channel.connect(addr);
state.controlChannel = channel;
state.controlWriter = new PrintWriter(
new OutputStreamWriter(Channels.newOutputStream(channel), StandardCharsets.UTF_8), true);
// Send authentication token
state.controlWriter.println("{\"type\":\"auth\",\"token\":\"" + state.controlToken + "\"}");
logger.info("Connected to control socket for call {}", state.callId);
// Flush any pending control messages
for (var msg : state.pendingControlMessages) {
state.controlWriter.println(msg);
}
state.pendingControlMessages.clear();
// Start reading control events
Thread.ofVirtual().name("control-read-" + state.callId).start(() -> {
readControlEvents(state);
});
return;
} catch (IOException e) {
logger.debug("Control socket connect attempt {} failed: {}", attempt, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
logger.warn("Failed to connect to control socket for call {} after retries", state.callId);
}
private void readControlEvents(CallState state) {
try (var reader = new BufferedReader(
new InputStreamReader(Channels.newInputStream(state.controlChannel), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
logger.debug("Control event for call {}: {}", state.callId, line);
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={}",
state.callId, state.inputDeviceName, state.outputDeviceName);
}
case "sendOffer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
sendOfferViaSignal(state, opaque);
}
case "sendAnswer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
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()));
}
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 {
sendHangupViaSignal(state, hangupType);
}
}
case "sendBusy" -> {
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 {}: {}", state.callId, message);
endCall(state.callId, "tunnel_error");
}
default -> {
logger.debug("Unknown control event type '{}' for call {}", type, state.callId);
}
}
} catch (Exception e) {
logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage());
}
}
} catch (IOException e) {
logger.debug("Control read ended for call {}: {}", 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
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);
}
private void sendAcceptIfReady(CallState state) {
if (state.acceptPending && state.controlWriter != null) {
state.acceptPending = false;
logger.debug("Sending deferred accept for call {}", state.callId);
state.controlWriter.println("{\"type\":\"accept\"}");
}
}
private void sendOfferViaSignal(CallState state, byte[] opaque) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var offerMessage = new org.whispersystems.signalservice.api.messages.calls.OfferMessage(state.callId,
org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.AUDIO_CALL,
opaque);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forOffer(
offerMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent offer via Signal for call {}", state.callId);
} catch (Exception e) {
logger.warn("Failed to send offer for call {}", state.callId, e);
}
}
private void sendAnswerViaSignal(CallState state, byte[] opaque) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var answerMessage = new org.whispersystems.signalservice.api.messages.calls.AnswerMessage(state.callId, opaque);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forAnswer(
answerMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent answer via Signal for call {}", state.callId);
} catch (Exception e) {
logger.warn("Failed to send answer for call {}", state.callId, e);
}
}
private void sendIceViaSignal(CallState state, List<byte[]> opaqueList) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var iceUpdates = opaqueList.stream()
.map(opaque -> new org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage(
state.callId, opaque))
.toList();
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forIceUpdates(
iceUpdates, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), state.callId);
} catch (Exception e) {
logger.warn("Failed to send ICE for call {}", state.callId, e);
}
}
private void sendBusyViaSignal(CallState state) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(state.callId);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy(
busyMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send busy for call {}", state.callId, e);
}
}
private void sendHangupViaSignal(CallState state, String hangupType) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var type = switch (hangupType) {
case "accepted", "acceptedonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.ACCEPTED;
case "declined", "declinedonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.DECLINED;
case "busy", "busyonanotherdevice" ->
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.BUSY;
default -> org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL;
};
var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(
state.callId, type, 0);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup(
hangupMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
logger.info("Sent hangup ({}) via Signal for call {}", hangupType, state.callId);
} catch (Exception e) {
logger.warn("Failed to send hangup for call {}", state.callId, e);
}
}
private byte[] getRemoteIdentityKey(CallState state) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var serviceId = address.getServiceId();
var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId);
if (identityInfo != null) {
return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize());
}
} catch (Exception e) {
logger.warn("Failed to get remote identity key for call {}", state.callId, e);
}
logger.warn("Using local identity key as fallback for remote identity key");
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
}
/**
* 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(byte[] serializedKey) {
if (serializedKey.length == 33 && serializedKey[0] == 0x05) {
return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length);
}
return serializedKey;
}
/** Format call ID as unsigned for JSON (tunnel binary expects u64). */
private static String callIdJson(long callId) {
return Long.toUnsignedString(callId);
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
}
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.info("Call {} ended: {}", 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)) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(callId,
org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL, 0);
var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup(
hangupMessage, null);
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (Exception e) {
logger.warn("Failed to send hangup to remote for call {}", callId, e);
}
}
// Send hangup via control channel before killing process
if (state.controlWriter != null) {
try {
state.controlWriter.println("{\"type\":\"hangup\"}");
} catch (Exception e) {
logger.debug("Failed to send hangup via control channel", e);
}
}
// Close control channel
if (state.controlChannel != null) {
try {
state.controlChannel.close();
} catch (IOException e) {
logger.debug("Failed to close control channel for call {}", callId, e);
}
}
// Kill tunnel process
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
state.tunnelProcess.destroy();
}
// Clean up socket directory
try {
Files.deleteIfExists(Path.of(state.controlSocketPath));
Files.deleteIfExists(state.socketDir);
} catch (IOException e) {
logger.debug("Failed to clean up socket directory for call {}", callId, e);
}
}
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.info("Call {} ring timeout", callId);
try {
hangupCall(callId);
} catch (IOException e) {
logger.warn("Failed to hangup timed-out call {}", callId, e);
endCall(callId, "ring_timeout");
}
}
}
private static long generateCallId() {
return new SecureRandom().nextLong() & Long.MAX_VALUE;
}
@Override
public void close() {
scheduler.shutdownNow();
for (var callId : new ArrayList<>(activeCalls.keySet())) {
endCall(callId, "shutdown");
}
}
// --- Internal call state tracking ---
static class CallState {
final long callId;
volatile CallInfo.State state;
final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
final RecipientIdentifier.Single recipientIdentifier;
final boolean isOutgoing;
final String controlSocketPath;
final Path socketDir;
volatile String inputDeviceName;
volatile String outputDeviceName;
volatile Process tunnelProcess;
volatile SocketChannel controlChannel;
volatile PrintWriter controlWriter;
volatile String controlToken;
// Raw offer opaque for incoming calls (forwarded to subprocess)
volatile byte[] rawOfferOpaque;
// Control messages queued before the control channel connects
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
// Accept deferred until tunnel reports Ringing state
volatile boolean acceptPending = false;
CallState(
long callId,
CallInfo.State state,
org.asamk.signal.manager.api.RecipientAddress recipientAddress,
RecipientIdentifier.Single recipientIdentifier,
boolean isOutgoing,
String controlSocketPath,
Path socketDir
) {
this.callId = callId;
this.state = state;
this.recipientAddress = recipientAddress;
this.recipientIdentifier = recipientIdentifier;
this.isOutgoing = isOutgoing;
this.controlSocketPath = controlSocketPath;
this.socketDir = socketDir;
}
CallInfo toCallInfo() {
return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing);
}
}
}

View File

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

View File

@ -35,6 +35,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
@ -70,8 +71,13 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@ -273,13 +279,18 @@ public final class IncomingMessageHandler {
return List.of();
} else {
List<HandleAction> actions;
Map<String, String> longTexts;
if (content != null) {
actions = handleMessage(envelope, content, receiveConfig);
final var results = handleMessage(envelope, content, receiveConfig);
actions = results.first();
longTexts = results.second();
} else {
actions = List.of();
longTexts = Map.of();
}
handler.handleMessage(MessageEnvelope.from(envelope,
content,
longTexts,
account.getRecipientResolver(),
account.getRecipientAddressResolver(),
context.getAttachmentHelper()::getAttachmentFile,
@ -288,12 +299,13 @@ public final class IncomingMessageHandler {
}
}
public List<HandleAction> handleMessage(
public Pair<List<HandleAction>, Map<String, String>> handleMessage(
SignalServiceEnvelope envelope,
SignalServiceContent content,
ReceiveConfig receiveConfig
) {
var actions = new ArrayList<HandleAction>();
final var actions = new ArrayList<HandleAction>();
final var longTexts = new HashMap<String, String>();
final var senderDeviceAddress = getSender(envelope, content);
final var sender = senderDeviceAddress.recipientId();
final var senderServiceId = senderDeviceAddress.serviceId();
@ -368,11 +380,13 @@ public final class IncomingMessageHandler {
message.getTimestamp()));
}
actions.addAll(handleSignalServiceDataMessage(message,
final var dataResults = handleSignalServiceDataMessage(message,
false,
senderDeviceAddress,
destination,
receiveConfig));
receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
}
if (content.getStoryMessage().isPresent()) {
@ -382,10 +396,52 @@ public final class IncomingMessageHandler {
if (content.getSyncMessage().isPresent()) {
var syncMessage = content.getSyncMessage().get();
actions.addAll(handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig));
final var syncResults = handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig);
actions.addAll(syncResults.first());
longTexts.putAll(syncResults.second());
}
return actions;
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender);
}
return new Pair<>(actions, longTexts);
}
private void handleCallMessage(
final org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage callMessage,
final org.asamk.signal.manager.storage.recipients.RecipientId sender
) {
var callManager = context.getCallManager();
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, offer.getId(), type, offer.getOpaque());
});
callMessage.getAnswerMessage().ifPresent(answer ->
callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque()));
callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> {
for (var ice : iceUpdates) {
callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque());
}
});
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(
@ -471,19 +527,20 @@ public final class IncomingMessageHandler {
}
}
private List<HandleAction> handleSyncMessage(
private Pair<List<HandleAction>, Map<String, String>> handleSyncMessage(
final SignalServiceEnvelope envelope,
final SignalServiceSyncMessage syncMessage,
final DeviceAddress sender,
final ReceiveConfig receiveConfig
) {
var actions = new ArrayList<HandleAction>();
final var actions = new ArrayList<HandleAction>();
final var longTexts = new HashMap<String, String>();
account.setMultiDevice(true);
if (syncMessage.getSent().isPresent()) {
var message = syncMessage.getSent().get();
final var destination = message.getDestination().orElse(null);
if (message.getDataMessage().isPresent()) {
actions.addAll(handleSignalServiceDataMessage(message.getDataMessage().get(),
final var dataResults = handleSignalServiceDataMessage(message.getDataMessage().get(),
true,
sender,
destination == null
@ -491,7 +548,9 @@ public final class IncomingMessageHandler {
: new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination.getServiceId(),
0),
receiveConfig));
receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
}
if (message.getStoryMessage().isPresent()) {
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
@ -643,7 +702,7 @@ public final class IncomingMessageHandler {
actions.add(RetrieveDeviceNameAction.create());
}
}
return actions;
return new Pair<>(actions, longTexts);
}
private SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
@ -709,15 +768,21 @@ public final class IncomingMessageHandler {
}
}
var groupId = GroupUtils.getGroupId(groupContext);
var group = context.getGroupHelper().getGroup(groupId);
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
final var groupId = GroupUtils.getGroupId(groupContext);
final var group = context.getGroupHelper().getGroup(groupId);
if (message != null && message.getAdminDelete().isPresent() && (group == null || !group.isAdmin(recipientId))) {
return true;
}
if (group == null) {
return false;
}
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (!group.isMember(recipientId) && !(
group.isPendingMember(recipientId) && message != null && message.isGroupV2Update()
)) {
@ -736,13 +801,14 @@ public final class IncomingMessageHandler {
return false;
}
private List<HandleAction> handleSignalServiceDataMessage(
private Pair<List<HandleAction>, Map<String, String>> handleSignalServiceDataMessage(
SignalServiceDataMessage message,
boolean isSync,
DeviceAddress source,
DeviceAddress destination,
ReceiveConfig receiveConfig
) {
final var longTexts = new HashMap<String, String>();
var actions = new ArrayList<HandleAction>();
if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get();
@ -837,6 +903,17 @@ public final class IncomingMessageHandler {
if (message.getAttachments().isPresent()) {
for (var attachment : message.getAttachments().get()) {
context.getAttachmentHelper().downloadAttachment(attachment);
if (attachment.isPointer()) {
final var file = context.getAttachmentHelper().getAttachmentFile(attachment.asPointer());
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
try {
final var longText = Files.readString(file.toPath());
longTexts.put(attachment.asPointer().getRemoteId().toString(), longText);
} catch (IOException e) {
logger.warn("Failed to read long text attachment, ignoring", e);
}
}
}
}
}
if (message.getSharedContacts().isPresent()) {
@ -866,6 +943,21 @@ public final class IncomingMessageHandler {
}
}
}
} else {
if (message.getAttachments().isPresent()) {
for (var attachment : message.getAttachments().get()) {
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
try {
context.getAttachmentHelper().retrieveAttachment(attachment, in -> {
final var longText = new String(in.readAllBytes(), StandardCharsets.UTF_8);
longTexts.put(attachment.asPointer().getRemoteId().toString(), longText);
});
} catch (IOException e) {
logger.warn("Failed to download long text attachment, ignoring", e);
}
}
}
}
}
if (message.getGiftBadge().isPresent()) {
handleIncomingGiftBadge(message.getGiftBadge().get());
@ -886,7 +978,7 @@ public final class IncomingMessageHandler {
.enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey()));
}
}
return actions;
return new Pair<>(actions, longTexts);
}
private void handleIncomingGiftBadge(final SignalServiceDataMessage.GiftBadge giftBadge) {

View File

@ -145,11 +145,8 @@ public class RecipientHelper {
try {
final var usernameLinkUrl = UsernameLinkUrl.fromUri(username);
final var components = usernameLinkUrl.getComponents();
final var encryptedUsername = handleResponseException(dependencies.getUsernameApi()
.getEncryptedUsernameFromLinkServerId(components.getServerId()));
final var link = new Username.UsernameLink(components.getEntropy(), encryptedUsername);
return Username.fromLink(link);
return handleResponseException(dependencies.getUsernameApi()
.getDecryptedUsernameFromLinkServerIdAndEntropy(components.getServerId(), components.getEntropy()));
} catch (UsernameLinkUrl.InvalidUsernameLinkException e) {
return new Username(username);
}

View File

@ -19,6 +19,9 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
@ -105,6 +108,12 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
@ -163,6 +172,7 @@ public class ManagerImpl implements Manager {
private boolean isReceivingSynchronous;
private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
private final Set<CallEventListener> callEventListeners = new HashSet<>();
private final List<Runnable> closedListeners = new ArrayList<>();
private final List<Runnable> addressChangedListeners = new ArrayList<>();
private final CompositeDisposable disposable = new CompositeDisposable();
@ -993,6 +1003,77 @@ public class ManagerImpl implements Manager {
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendAdminDelete(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier.Group> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var adminDelete = new SignalServiceDataMessage.AdminDelete(authorServiceId, targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withAdminDelete(adminDelete);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder,
recipients.stream().map(r -> (RecipientIdentifier) r).collect(Collectors.toSet()),
notifySelf);
}
@Override
public SendMessageResults sendPinMessage(
int pinDuration,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var duration = pinDuration >= 0 ? pinDuration : null;
final var forever = pinDuration < 0;
final var pinnedMessage = new SignalServiceDataMessage.PinnedMessage(authorServiceId,
targetSentTimestamp,
duration,
forever);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPinnedMessage(pinnedMessage);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var unpinnedMessage = new SignalServiceDataMessage.UnpinnedMessage(authorServiceId, targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withUnpinnedMessage(unpinnedMessage);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendPaymentNotificationMessage(
byte[] receipt,
@ -1631,6 +1712,22 @@ public class ManagerImpl implements Manager {
}
}
@Override
public void addCallEventListener(final CallEventListener listener) {
synchronized (callEventListeners) {
callEventListeners.add(listener);
}
context.getCallManager().addCallEventListener(listener);
}
@Override
public void removeCallEventListener(final CallEventListener listener) {
synchronized (callEventListeners) {
callEventListeners.remove(listener);
}
context.getCallManager().removeCallEventListener(listener);
}
@Override
public InputStream retrieveAttachment(final String id) throws IOException {
return context.getAttachmentHelper().retrieveAttachment(id).getStream();
@ -1688,6 +1785,132 @@ public class ManagerImpl implements Manager {
return streamDetails.getStream();
}
// --- Voice call methods ---
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
return context.getCallManager().startOutgoingCall(recipient);
}
@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 void rejectCall(final long callId) throws IOException {
context.getCallManager().rejectCall(callId);
}
@Override
public List<CallInfo> listActiveCalls() {
return context.getCallManager().listActiveCalls();
}
@Override
public void sendCallOffer(
final RecipientIdentifier.Single recipient,
final CallOffer offer
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var offerMessage = new OfferMessage(offer.callId(),
offer.type() == CallOffer.Type.VIDEO ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL,
offer.opaque());
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendCallAnswer(
final RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var answerMessage = new AnswerMessage(callId, answerOpaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendIceUpdate(
final RecipientIdentifier.Single recipient,
final long callId,
final List<byte[]> iceCandidates
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var iceUpdates = iceCandidates.stream()
.map(opaque -> new IceUpdateMessage(callId, opaque))
.toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendHangup(
final RecipientIdentifier.Single recipient,
final long callId,
final MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var hangupType = switch (type) {
case NORMAL -> HangupMessage.Type.NORMAL;
case ACCEPTED -> HangupMessage.Type.ACCEPTED;
case DECLINED -> HangupMessage.Type.DECLINED;
case BUSY -> HangupMessage.Type.BUSY;
case NEED_PERMISSION -> HangupMessage.Type.NEED_PERMISSION;
};
var hangupMessage = new HangupMessage(callId, hangupType, 0);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendBusy(
final RecipientIdentifier.Single recipient,
final long callId
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public List<TurnServer> getTurnServerInfo() throws IOException {
return context.getCallManager().getTurnServers();
}
@Override
public void close() {
Thread thread;
@ -1700,6 +1923,12 @@ public class ManagerImpl implements Manager {
if (thread != null) {
stopReceiveThread(thread);
}
synchronized (callEventListeners) {
for (var listener : callEventListeners) {
context.getCallManager().removeCallEventListener(listener);
}
callEventListeners.clear();
}
context.close();
executor.close();

View File

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

View File

@ -0,0 +1,32 @@
// In-call control messages carried over the RTP data channel.
// signal-cli hand-codes the parsing in RtpDataProtobuf.java rather than using protoc.
syntax = "proto2";
package rtp_data;
option java_package = "org.asamk.signal.manager.calling.proto";
option java_outer_classname = "RtpDataProtos";
message Accepted {}
message Hangup {
optional uint32 id = 1;
}
message SenderStatus {
optional bool audio_enabled = 1;
optional bool video_enabled = 2;
optional bool sharing_screen = 3;
}
message Receiver {
optional uint32 id = 1;
}
// Top-level RTP data message
message Data {
optional Accepted accepted = 1;
optional Hangup hangup = 2;
optional SenderStatus sender_status = 3;
optional Receiver receiver = 4;
}

View File

@ -0,0 +1,32 @@
// RingRTC signaling protobuf definitions
// These define the structure of the opaque blobs inside Signal call Offer/Answer messages.
// signal-cli hand-codes the parsing in SignalingProtobuf.java rather than using protoc.
syntax = "proto2";
package signaling;
option java_package = "org.asamk.signal.manager.calling.proto";
option java_outer_classname = "SignalingProtos";
message VideoCodec {
enum Type {
VP8 = 0;
H264 = 1;
VP9 = 2;
}
optional Type type = 1;
optional uint32 level = 2;
}
message ConnectionParametersV4 {
optional bytes public_key = 1; // x25519 public key (32 bytes)
optional string ice_ufrag = 2;
optional string ice_pwd = 3;
repeated VideoCodec receive_video_codecs = 4;
optional uint64 max_bitrate_bps = 5;
}
// The top-level opaque blob inside an OfferMessage or AnswerMessage
message Opaque {
optional ConnectionParametersV4 connection_parameters_v4 = 1;
}

View File

@ -0,0 +1,464 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.RecipientAddress;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
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.assertNotEquals;
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_JSON;
private static final MethodHandle ESCAPE_JSON;
private static final MethodHandle GENERATE_CALL_ID;
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_JSON = lookup.findStatic(CallManager.class, "callIdJson",
MethodType.methodType(String.class, long.class));
ESCAPE_JSON = lookup.findStatic(CallManager.class, "escapeJson",
MethodType.methodType(String.class, String.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 String callIdJson(long callId) throws Throwable {
return (String) CALL_ID_JSON.invokeExact(callId);
}
private static String escapeJson(String s) throws Throwable {
return (String) ESCAPE_JSON.invokeExact(s);
}
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) {
var address = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null);
return new CallManager.CallState(
callId,
initialState,
address,
new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"),
true,
"/tmp/sc-test/ctrl.sock",
Path.of("/tmp/sc-test")
);
}
// ========================================================================
// 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);
}
// ========================================================================
// callIdJson tests
// ========================================================================
@Test
void callIdJson_zero() throws Throwable {
assertEquals("0", callIdJson(0L));
}
@Test
void callIdJson_positiveLong() throws Throwable {
assertEquals("8230211930154373276", callIdJson(8230211930154373276L));
}
@Test
void callIdJson_negativeLongBecomesUnsigned() throws Throwable {
// -1L as unsigned is 2^64 - 1 = 18446744073709551615
assertEquals("18446744073709551615", callIdJson(-1L));
}
@Test
void callIdJson_longMinValueBecomesUnsigned() throws Throwable {
// Long.MIN_VALUE as unsigned is 2^63 = 9223372036854775808
assertEquals("9223372036854775808", callIdJson(Long.MIN_VALUE));
}
@Test
void callIdJson_longMaxValue() throws Throwable {
assertEquals("9223372036854775807", callIdJson(Long.MAX_VALUE));
}
// ========================================================================
// escapeJson tests
// ========================================================================
@Test
void escapeJson_null() throws Throwable {
assertEquals("", escapeJson(null));
}
@Test
void escapeJson_empty() throws Throwable {
assertEquals("", escapeJson(""));
}
@Test
void escapeJson_noSpecialChars() throws Throwable {
assertEquals("hello world", escapeJson("hello world"));
}
@Test
void escapeJson_backslash() throws Throwable {
assertEquals("path\\\\to\\\\file", escapeJson("path\\to\\file"));
}
@Test
void escapeJson_doubleQuote() throws Throwable {
assertEquals("say \\\"hello\\\"", escapeJson("say \"hello\""));
}
@Test
void escapeJson_newline() throws Throwable {
assertEquals("line1\\nline2", escapeJson("line1\nline2"));
}
@Test
void escapeJson_carriageReturn() throws Throwable {
assertEquals("line1\\rline2", escapeJson("line1\rline2"));
}
@Test
void escapeJson_allSpecialChars() throws Throwable {
assertEquals("a\\\\b\\\"c\\nd\\re", escapeJson("a\\b\"c\nd\re"));
}
// ========================================================================
// generateCallId tests
// ========================================================================
@Test
void generateCallId_alwaysNonNegative() throws Throwable {
for (int i = 0; i < 200; i++) {
long id = generateCallId();
assertTrue(id >= 0, "generateCallId returned negative: " + id);
}
}
@Test
void generateCallId_producesVariation() throws Throwable {
long first = generateCallId();
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();
assertEquals(42L, info.callId());
assertEquals(CallInfo.State.CONNECTED, info.state());
assertEquals("+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();
assertEquals(CallInfo.State.RINGING_INCOMING, info.state());
assertEquals(null, info.inputDeviceName());
assertEquals(null, info.outputDeviceName());
}
// ========================================================================
// 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

@ -262,6 +262,10 @@ Only works, if this is the primary device.
Specify the uri contained in the QR code shown by the new device.
You will need the full URI such as "sgnl://linkdevice?uuid=...&pub_key=..." (formerly "tsdevice:/?uuid=...") Make sure to enclose it in quotation marks for shells.
=== listAccounts
Show a list of registered accounts.
=== listDevices
Show a list of linked devices.
@ -287,6 +291,27 @@ One or more numbers to check.
[--username [USERNAME ...]]::
One or more usernames or username links to check.
=== sendAdminDelete
Send admin delete message for a previously received or sent message.
Admin delete is used by group admins to remove messages from all group members.
Only works with group recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding (required).
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to admin delete.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to admin delete.
*--story*::
Admin delete a story instead of a normal message.
=== send
Send a message to another user or group.
@ -385,6 +410,10 @@ Clear session state and send end session message.
*--edit-timestamp*::
Specify the timestamp of a previous message with the recipient or group to send an edited message.
*--no-urgent*::
Send the message without the urgent flag, so no push notification is triggered for the recipient.
The message will still be delivered in real-time if the recipient's app is active.
=== sendPollCreate
Send a poll create message to another user or group.
@ -413,6 +442,7 @@ By default, recipients can select multiple options.
*-o* OPTION [OPTION ...], *--option* OPTION [OPTION ...]*::
The options for the poll.
Between 2 and 10 options must be specified.
=== sendPollVote
@ -497,6 +527,60 @@ The base64 encoded receipt blob.
*--note* NOTE::
Specify a note for the payment notification.
=== sendPinMessage
Send pin message for a previously received or sent message.
RECIPIENT::
Specify the recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the pin message to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-d* DURATION, *--pin-duration* DURATION::
Specify the pin duration in seconds.
Use -1 for forever (default: -1).
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to pin.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to pin.
=== sendUnpinMessage
Send unpin message for a previously received or sent message.
RECIPIENT::
Specify the recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the unpin message to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to unpin.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to unpin.
=== sendReaction
Send reaction to a previously received or sent message.
@ -510,6 +594,12 @@ Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the reaction to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-e* EMOJI, *--emoji* EMOJI::
Specify the emoji, should be a single unicode grapheme cluster.
@ -532,6 +622,9 @@ Send a read or viewed receipt to a previously received message.
RECIPIENT::
Specify the sender.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to which to react.
@ -578,7 +671,7 @@ In json mode this is outputted as one json object per line.
Number of seconds to wait for new messages (negative values disable timeout).
Default is 5 seconds.
*--max-messages*::
*--max-messages* MAX_MESSAGES::
Maximum number of messages to receive, before returning.
*--ignore-attachments*::
@ -702,10 +795,10 @@ Find contacts with the given contact or profile name.
*--detailed*::
List the contacts with more details.
If output=json, then this is always set
If output=json, then this is always set.
*--internal*::
Include internal information that's normally not user visible
Include internal information that's normally not user visible.
=== listIdentities
@ -786,6 +879,18 @@ New note.
Set expiration time of messages (seconds).
To disable expiration set expiration time to 0.
=== updateDevice
Update a linked device.
Only works, if this is the primary device.
*-d* DEVICE_ID, *--device-id* DEVICE_ID::
Specify the device you want to update.
Use listDevices to see the deviceIds.
*-n* NAME, *--device-name* NAME::
Specify a name to describe the given device.
=== removeContact
Remove the info of a given contact
@ -1007,6 +1112,10 @@ The challenge token from the failed send attempt.
*--captcha* CAPTCHA::
The captcha result, starting with signalcaptcha://
=== version
Show version information.
== Examples
Register a number (with SMS verification)::

View File

@ -19,12 +19,19 @@ PATH_MAIN="$PATH_TEST_CONFIG/main"
PATH_LINK="$PATH_TEST_CONFIG/link"
if [ "$NATIVE" -eq 1 ]; then
./gradlew nativeCompile
SIGNAL_CLI="$PWD/build/native/nativeCompile/signal-cli"
elif [ "$JSON_RPC" -eq 1 ]; then
export RUST_BACKTRACE=1
(cd client && cargo build)
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_MAIN" --service-environment="staging" --log-file="$PATH_MAIN/log" daemon --socket --receive-mode=manual&
./gradlew installDist
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_LINK" --service-environment="staging" --log-file="$PATH_LINK/log" daemon --tcp --receive-mode=manual&
sleep 5
if [ ! -z "$GRAALVM_HOME" ]; then
export JAVA_HOME=$GRAALVM_HOME
export SIGNAL_CLI_OPTS="-agentlib:native-image-agent=config-merge-dir=graalvm-config-dir-main/"
fi
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_MAIN" --service-environment="staging" --log-file="$PATH_MAIN/log" daemon --socket --receive-mode=manual&
sleep 15
SIGNAL_CLI="$PWD/client/target/debug/signal-cli-client"
else
./gradlew installDist
@ -113,10 +120,10 @@ fi
sleep 5
run_main listAccounts
run_main --output=json listAccounts
run_main --scrub-log listAccounts
if [ "$JSON_RPC" -eq 0 ]; then
run_main --output=json listAccounts
run_main --scrub-log listAccounts
## DBus
#run_main -a "$NUMBER_1" --dbus send "$NUMBER_2" -m daemon_not_running || true
#run_main daemon &

View File

@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
.orElse("Signal-Android/8.0.3");
.orElse("Signal-Android/8.1.2");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION;

View File

@ -224,6 +224,11 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final var unpinMessage = message.unpinMessage().get();
printUnpinMessage(writer.indentedWriter(), unpinMessage);
}
if (message.adminDelete().isPresent()) {
writer.println("Admin Delete:");
final var adminDelete = message.adminDelete().get();
printAdminDelete(writer.indentedWriter(), adminDelete);
}
}
private void printEditMessage(PlainTextWriter writer, MessageEnvelope.Edit message) {
@ -648,6 +653,11 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Target timestamp: {}", DateUtils.formatTimestamp(unpinMessage.targetSentTimestamp()));
}
private void printAdminDelete(final PlainTextWriter writer, final MessageEnvelope.Data.AdminDelete adminDelete) {
writer.println("Target author: {}", formatContact(adminDelete.targetAuthor()));
writer.println("Target timestamp: {}", DateUtils.formatTimestamp(adminDelete.targetSentTimestamp()));
}
private String formatContact(RecipientAddress address) {
final var number = address.getLegacyIdentifier();
final var name = m.getContactOrProfileName(RecipientIdentifier.Single.fromAddress(address));

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,15 +35,18 @@ public class Commands {
addCommand(new ListStickerPacksCommand());
addCommand(new QuitGroupCommand());
addCommand(new ReceiveCommand());
addCommand(new RejectCallCommand());
addCommand(new RegisterCommand());
addCommand(new RemoveContactCommand());
addCommand(new RemoveDeviceCommand());
addCommand(new RemovePinCommand());
addCommand(new RemoteDeleteCommand());
addCommand(new SendAdminDeleteCommand());
addCommand(new SendCommand());
addCommand(new SendContactsCommand());
addCommand(new SendMessageRequestResponseCommand());
addCommand(new SendPaymentNotificationCommand());
addCommand(new SendPinMessageCommand());
addCommand(new SendPollCreateCommand());
addCommand(new SendPollVoteCommand());
addCommand(new SendPollTerminateCommand());
@ -48,7 +54,9 @@ public class Commands {
addCommand(new SendReceiptCommand());
addCommand(new SendSyncRequestCommand());
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

@ -144,6 +144,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
contact.nickNameFamilyName(),
contact.note(),
contact.color(),
contact.isArchived(),
contact.isBlocked(),
contact.isHidden(),
contact.messageExpirationTime(),

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,90 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
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.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import java.util.Set;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendAdminDeleteCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendAdminDelete";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send admin delete message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("+");
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to admin delete.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to admin delete.");
subparser.addArgument("--story")
.help("Admin delete a story instead of a normal message")
.action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var groupIdStrings = ns.<String>getList("group-id");
Set<RecipientIdentifier.Group> groupIdentifiers = CommandUtil.getGroupIdentifiers(groupIdStrings);
if (groupIdentifiers.isEmpty()) {
throw new UserErrorException("Admin delete requires group IDs");
}
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor,
m.getSelfNumber());
try {
final var results = m.sendAdminDelete(targetAuthorIdentifier,
targetTimestamp,
groupIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

View File

@ -0,0 +1,104 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
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.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendPinMessageCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendPinMessage";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send pin message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("*");
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
subparser.addArgument("-u", "--username").help("Specify the recipient username or username link.").nargs("*");
subparser.addArgument("--note-to-self").help("Send the pin message to self.").action(Arguments.storeTrue());
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-d", "--pin-duration")
.type(int.class)
.setDefault(-1)
.help("Specify the pin duration in seconds. Use -1 for forever.");
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to pin.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to pin.");
subparser.addArgument("--story").help("Pin a story instead of a normal message").action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self"));
final var recipientStrings = ns.<String>getList("recipient");
final var groupIdStrings = ns.<String>getList("group-id");
final var usernameStrings = ns.<String>getList("username");
final var recipientIdentifiers = CommandUtil.getRecipientIdentifiers(m,
isNoteToSelf,
recipientStrings,
groupIdStrings,
usernameStrings);
final var pinDuration = ns.getInt("pin-duration");
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier;
if (targetAuthor == null && recipientIdentifiers.size() == 1 && recipientIdentifiers.stream()
.findFirst()
.get() instanceof RecipientIdentifier.Single single) {
targetAuthorIdentifier = single;
} else {
targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber());
}
try {
final var results = m.sendPinMessage(pinDuration,
targetAuthorIdentifier,
targetTimestamp,
recipientIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

View File

@ -24,6 +24,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendPollCreateCommand implements JsonRpcLocalCommand {
private static final Logger logger = LoggerFactory.getLogger(SendPollCreateCommand.class);
private static final int MAX_POLL_OPTIONS = 10;
@Override
public String getName() {
@ -72,6 +73,9 @@ public class SendPollCreateCommand implements JsonRpcLocalCommand {
if (options.size() < 2) {
throw new UserErrorException("Poll needs at least two options");
}
if (options.size() > MAX_POLL_OPTIONS) {
throw new UserErrorException("Poll cannot have more than " + MAX_POLL_OPTIONS + " options");
}
try {
var results = m.sendPollCreateMessage(question, !noMulti, options, recipientIdentifiers, notifySelf);

View File

@ -0,0 +1,100 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
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.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendUnpinMessageCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendUnpinMessage";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send unpin message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("*");
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
subparser.addArgument("-u", "--username").help("Specify the recipient username or username link.").nargs("*");
subparser.addArgument("--note-to-self").help("Send the unpin message to self.").action(Arguments.storeTrue());
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to unpin.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to unpin.");
subparser.addArgument("--story")
.help("Unpin a story instead of a normal message")
.action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self"));
final var recipientStrings = ns.<String>getList("recipient");
final var groupIdStrings = ns.<String>getList("group-id");
final var usernameStrings = ns.<String>getList("username");
final var recipientIdentifiers = CommandUtil.getRecipientIdentifiers(m,
isNoteToSelf,
recipientStrings,
groupIdStrings,
usernameStrings);
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier;
if (targetAuthor == null && recipientIdentifiers.size() == 1 && recipientIdentifiers.stream()
.findFirst()
.get() instanceof RecipientIdentifier.Single single) {
targetAuthorIdentifier = single;
} else {
targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber());
}
try {
final var results = m.sendUnpinMessage(targetAuthorIdentifier,
targetTimestamp,
recipientIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

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

@ -493,6 +493,40 @@ public class DbusManagerImpl implements Manager {
groupId));
}
@Override
public SendMessageResults sendAdminDelete(
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier.Group> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendPinMessage(
final int pinDuration,
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendUnpinMessage(
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendPaymentNotificationMessage(
final byte[] receipt,
@ -879,6 +913,73 @@ 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 org.asamk.signal.manager.api.CallInfo startCall(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public org.asamk.signal.manager.api.CallInfo acceptCall(final long callId) {
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 void rejectCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<org.asamk.signal.manager.api.CallInfo> listActiveCalls() {
return java.util.List.of();
}
@Override
public void sendCallOffer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final org.asamk.signal.manager.api.CallOffer offer) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendCallAnswer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final byte[] answerOpaque) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendIceUpdate(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final java.util.List<byte[]> iceCandidates) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendHangup(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final org.asamk.signal.manager.api.MessageEnvelope.Call.Hangup.Type type) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendBusy(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<org.asamk.signal.manager.api.TurnServer> getTurnServerInfo() {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void close() {
synchronized (this) {
@ -979,6 +1080,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty())),
Optional.empty(),
Optional.empty(),
@ -1027,6 +1129,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty()))),
Optional.empty(),
Optional.empty(),
@ -1107,6 +1210,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty())),
Optional.empty(),
Optional.empty())),

View File

@ -0,0 +1,21 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
public record JsonAdminDelete(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
) {
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
final var address = adminDelete.targetAuthor();
final var targetAuthor = address.getLegacyIdentifier();
final var targetAuthorNumber = address.number().orElse(null);
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
final var targetSentTimestamp = adminDelete.targetSentTimestamp();
return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
}
}

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

@ -16,6 +16,7 @@ public record JsonContact(
String nickFamilyName,
String note,
String color,
boolean isArchived,
boolean isBlocked,
boolean isHidden,
int messageExpirationTime,

View File

@ -29,7 +29,8 @@ record JsonDataMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) JsonGroupInfo groupInfo,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonStoryContext storyContext,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonPinMessage pinMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonUnpinMessage unpinMessage
@JsonInclude(JsonInclude.Include.NON_NULL) JsonUnpinMessage unpinMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonAdminDelete adminDelete
) {
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
@ -75,6 +76,7 @@ record JsonDataMessage(
.toList() : null;
final var pinMessage = dataMessage.pinMessage().map(JsonPinMessage::from).orElse(null);
final var unpinMessage = dataMessage.unpinMessage().map(JsonUnpinMessage::from).orElse(null);
final var adminDelete = dataMessage.adminDelete().map(JsonAdminDelete::from).orElse(null);
return new JsonDataMessage(timestamp,
message,
@ -97,6 +99,7 @@ record JsonDataMessage(
groupInfo,
storyContext,
pinMessage,
unpinMessage);
unpinMessage,
adminDelete);
}
}

View File

@ -24,6 +24,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.channels.ClosedChannelException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -40,6 +41,7 @@ public class SignalJsonRpcDispatcherHandler {
private final boolean noReceiveOnStart;
private final Map<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
private final List<Pair<Manager, Manager.CallEventListener>> callEventHandlers = new ArrayList<>();
private SignalJsonRpcCommandHandler commandHandler;
public SignalJsonRpcDispatcherHandler(
@ -62,6 +64,11 @@ public class SignalJsonRpcDispatcherHandler {
c.addOnManagerRemovedHandler(this::unsubscribeReceive);
}
for (var m : c.getManagers()) {
subscribeCallEvents(m);
}
c.addOnManagerAddedHandler(this::subscribeCallEvents);
handleConnection();
}
@ -72,12 +79,33 @@ public class SignalJsonRpcDispatcherHandler {
subscribeReceive(m, true);
}
subscribeCallEvents(m);
final var currentThread = Thread.currentThread();
m.addClosedListener(currentThread::interrupt);
handleConnection();
}
private void subscribeCallEvents(final Manager manager) {
Manager.CallEventListener listener = (callInfo, reason) -> {
final var params = new ObjectNode(objectMapper.getNodeFactory());
params.set("account", params.textNode(manager.getSelfNumber()));
params.set("callEvent", objectMapper.valueToTree(
org.asamk.signal.json.JsonCallEvent.from(callInfo, reason)));
final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
logger.debug("Call event channel closed, removing listener");
}
}
};
manager.addCallEventListener(listener);
callEventHandlers.add(new Pair<>(manager, listener));
}
private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0);
private int subscribeReceive(final Manager manager, boolean internalSubscription) {
@ -141,6 +169,10 @@ public class SignalJsonRpcDispatcherHandler {
} finally {
receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler));
receiveHandlers.clear();
for (var pair : callEventHandlers) {
pair.first().removeCallEventListener(pair.second());
}
callEventHandlers.clear();
}
}

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,
@ -2225,6 +2354,27 @@
{
"type": "org.asamk.signal.dbus.DbusSignalImpl$DbusSignalIdentityImpl"
},
{
"type": "org.asamk.signal.json.JsonAdminDelete",
"methods": [
{
"name": "targetAuthor",
"parameterTypes": []
},
{
"name": "targetAuthorNumber",
"parameterTypes": []
},
{
"name": "targetAuthorUuid",
"parameterTypes": []
},
{
"name": "targetSentTimestamp",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.json.JsonAttachment",
"allDeclaredFields": true,
@ -5670,6 +5820,18 @@
}
]
},
{
"type": "org.signal.libsignal.protocol.SessionCipher$2",
"jniAccessible": true,
"methods": [
{
"name": "loadSignedPreKey",
"parameterTypes": [
"int"
]
}
]
},
{
"type": "org.signal.libsignal.protocol.SignalProtocolAddress",
"jniAccessible": true,
@ -5920,6 +6082,10 @@
"type": "org.signal.libsignal.protocol.state.internal.PreKeyStore",
"jniAccessible": true
},
{
"type": "org.signal.libsignal.protocol.state.internal.SignedPreKeyStore",
"jniAccessible": true
},
{
"type": "org.signal.libsignal.usernames.BadDiscriminatorCharacterException",
"jniAccessible": true,
@ -9654,6 +9820,27 @@
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_*"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AG"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AI"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AS"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BB"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BM"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BS"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CA"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE"
},
@ -9724,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);
}
}

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