mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-05-20 13:34:23 +00:00
Compare commits
28 Commits
ed0db52e51
...
fd0bb1cbc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd0bb1cbc4 | ||
|
|
9493381f57 | ||
|
|
32ada51c44 | ||
|
|
fafc5e3563 | ||
|
|
fa010d03cf | ||
|
|
0ea4838e01 | ||
|
|
2885ffeee8 | ||
|
|
6071291f16 | ||
|
|
37b8a4a996 | ||
|
|
af56a28b94 | ||
|
|
dfc7e3b495 | ||
|
|
e9114ae8fc | ||
|
|
f77a74d93f | ||
|
|
5cda87ee0e | ||
|
|
7384407823 | ||
|
|
7fa56a37fd | ||
|
|
8fcd953ece | ||
|
|
775236efc3 | ||
|
|
4b8dec26a9 | ||
|
|
d94e05c38c | ||
|
|
1bbf98fac0 | ||
|
|
c70515035f | ||
|
|
a9d235b7f1 | ||
|
|
92ded3fdf2 | ||
|
|
aa1ed9e233 | ||
|
|
3b6c199b1d | ||
|
|
6d22ceef24 | ||
|
|
54ff59737e |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@ -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: |
|
||||
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.github/workflows/codeql-analysis.yml
vendored
@ -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
|
||||
|
||||
76
.github/workflows/release.yml
vendored
76
.github/workflows/release.yml
vendored
@ -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) }}"
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
@ -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
11
client.Containerfile
Normal 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
572
client/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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")]
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
372
docs/CALL_TUNNEL.md
Normal 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.
|
||||
@ -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" }
|
||||
|
||||
@ -37,7 +37,11 @@ dependencies {
|
||||
}
|
||||
|
||||
tasks.named<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
if (!project.hasProperty("includeIntegration")) {
|
||||
excludeTags("integration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
21
lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java
Normal file
21
lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
public record CallOffer(
|
||||
long callId,
|
||||
Type type,
|
||||
byte[] opaque
|
||||
) {
|
||||
|
||||
public enum Type {
|
||||
AUDIO,
|
||||
VIDEO
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TurnServer(
|
||||
String username,
|
||||
String password,
|
||||
List<String> urls
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
32
lib/src/main/proto/rtp_data.proto
Normal file
32
lib/src/main/proto/rtp_data.proto
Normal 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;
|
||||
}
|
||||
32
lib/src/main/proto/signaling.proto
Normal file
32
lib/src/main/proto/signaling.proto
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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)::
|
||||
|
||||
15
run_tests.sh
15
run_tests.sh
@ -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 &
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -144,6 +144,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||
contact.nickNameFamilyName(),
|
||||
contact.note(),
|
||||
contact.color(),
|
||||
contact.isArchived(),
|
||||
contact.isBlocked(),
|
||||
contact.isHidden(),
|
||||
contact.messageExpirationTime(),
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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())),
|
||||
|
||||
21
src/main/java/org/asamk/signal/json/JsonAdminDelete.java
Normal file
21
src/main/java/org/asamk/signal/json/JsonAdminDelete.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
32
src/main/java/org/asamk/signal/json/JsonCallEvent.java
Normal file
32
src/main/java/org/asamk/signal/json/JsonCallEvent.java
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ public record JsonContact(
|
||||
String nickFamilyName,
|
||||
String note,
|
||||
String color,
|
||||
boolean isArchived,
|
||||
boolean isBlocked,
|
||||
boolean isHidden,
|
||||
int messageExpirationTime,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
110
src/test/java/org/asamk/signal/json/JsonCallEventTest.java
Normal file
110
src/test/java/org/asamk/signal/json/JsonCallEventTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user