Compare commits

...

22 Commits

Author SHA1 Message Date
BarbossHack
c03b96a800
Merge 05c1d7bc990095fa0c0c27fa76f7e36f95f7dff0 into d0ee90dbbcf04d91b1683646eb13b0e7c4794642 2026-04-11 16:32:33 +00:00
BarbossHack
05c1d7bc99 Add reproducible builds 2026-04-11 18:32:24 +02:00
AsamK
d0ee90dbbc Reformat files 2026-04-11 12:29:16 +02:00
AsamK
398faa50b0 Make address cache synchronized 2026-04-11 12:26:41 +02:00
AsamK
e9eabbeeb5 Add commits in early returns 2026-04-11 12:26:24 +02:00
tonycpsu
132dfb95dc
Fix SQLiteException in resolveRecipient by checking cache before opening connection (#2011) 2026-04-11 12:23:15 +02:00
AsamK
2651823d4d Fix add group member handling for already members 2026-04-11 11:56:50 +02:00
AsamK
4709cfacc7 Update multiple member roles in one change
Fixes #2009
2026-04-11 11:55:59 +02:00
AsamK
9bc4c0ecd8 Update libsignal-service 2026-04-10 18:24:57 +02:00
AsamK
763ddf85e6 Bump version to 0.14.2 2026-04-04 15:05:24 +02:00
AsamK
b2bab0d0dc Add libsignal-version file
Fixes #1964
2026-04-03 11:40:08 +02:00
AsamK
62fc96c4c9 Handle MustRequestNewCodeException
Fixes #1968
2026-04-03 11:20:46 +02:00
AsamK
2667688139 Update gradle wrapper 2026-04-03 11:20:01 +02:00
AsamK
990d1eab58 Add java 26 to ci build 2026-04-03 10:11:04 +02:00
AsamK
e6b33b8da7 Check for missing attachment id
Closes #1989
2026-04-02 22:06:10 +02:00
AsamK
d40f62ec21 Adapt exception handling to libsignal-service changes 2026-04-02 21:32:19 +02:00
AsamK
265369e353 Update libsignal-service 2026-04-01 22:47:54 +02:00
AsamK
d1106299fe Pass sender device id to ice handler 2026-04-01 22:47:54 +02:00
AsamK
7919a0f4aa Change subscribeCallEvents command to match subscribeReceive 2026-04-01 22:47:54 +02:00
AsamK
7a8a34f45e Some call refactoring 2026-04-01 22:47:54 +02:00
AsamK
0a777ea7df Some minor code improvements 2026-04-01 22:08:08 +02:00
AsamK
103a0807ca Update graalvm buildtools 2026-04-01 22:07:48 +02:00
71 changed files with 1792 additions and 1011 deletions

57
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
strategy:
matrix:
# The "reproducible" entry is used to build the project with the LTS Java version used in reproducible builds script.
# More Java versions can be added to test compatibility, eg. "26".
java: ["reproducible", "26"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
if [ "${{ matrix.java }}" != "reproducible" ]; then
export OVERRIDE_JAVA_VERSION="${{ matrix.java }}"
fi
./reproducible-builds/build.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-${{ matrix.java }}-${{ github.job }}
path: dist/*
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- 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@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -1,96 +0,0 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: write # to fetch code (actions/checkout) and submit dependency graph (gradle/gradle-build-action)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '25' ]
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
run: sudo apt update && sudo apt --no-install-recommends install -y asciidoc-base
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Build man page
run: |
cd man
make install
- name: Add man page to archive
run: |
version=$(tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|')
echo $version
tar --transform="flags=r;s|man|signal-cli-${version}/man|" -rf build/distributions/signal-cli-${version}.tar man/man{1,5}
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
java-version: '25'
cache: 'gradle'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- 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@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -5,8 +5,7 @@ on:
tags:
- v*
permissions:
contents: write # to fetch code (actions/checkout) and create release
permissions: {}
env:
IMAGE_NAME: signal-cli
@ -15,96 +14,25 @@ env:
REGISTRY_PASSWORD: ${{ github.token }}
jobs:
build:
uses: AsamK/signal-cli/.github/workflows/build.yml@master
ci_wf:
permissions:
contents: write
uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
signal_cli_version: ${{ steps.cli_ver.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
id: version
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Extract archive
run: |
tree .
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
rm -rf signal-cli-archive-*/
# - name: Get signal-client jar version
# id: lib_ver
# run: |
# JAR_PREFIX=libsignal-client-
# jar_file=$(find ./signal-cli-*/lib/ -name "$JAR_PREFIX*.jar")
# jar_version=$(echo "$jar_file" | xargs basename | sed "s/$JAR_PREFIX//; s/.jar//")
# echo "$jar_version"
# echo "signal_client_version=${jar_version}" >> $GITHUB_OUTPUT
#
# - name: Download signal-client builds
# env:
# RELEASES_URL: https://github.com/signalapp/libsignal/releases/download/
# FILE_NAMES: signal_jni.dll libsignal_jni.dylib
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# for file_name in $FILE_NAMES; do
# curl -sOL "${RELEASES_URL}/v${SIGNAL_CLIENT_VER}/${file_name}" # note: added v
# done
# tree .
- name: Compress native app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-native/signal-cli
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 }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# mv signal_jni.dll libsignal_jni.so
# zip -u ./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar ./libsignal_jni.so
# tar -czf signal-cli-${SIGNAL_CLI_VER}-Windows.tar.gz signal-cli-*/
#
# - name: Replace macOS lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# jar_file=./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar
# zip -d $jar_file libsignal_jni.so
# zip $jar_file libsignal_jni.dylib
# tar -czf signal-cli-${SIGNAL_CLI_VER}-macOS.tar.gz signal-cli-*/
mv ./signal-cli-reproducible-build/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release
id: create_release
@ -112,8 +40,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
release_name: v${{ steps.cli_ver.outputs.version }} # note: added `v`
tag_name: v${{ steps.version.outputs.version }} # note: added `v`
release_name: v${{ steps.version.outputs.version }} # note: added `v`
draft: true
- name: Upload archive
@ -122,19 +50,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload Linux 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.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive
uses: actions/upload-release-asset@v1
@ -142,9 +60,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.version.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
@ -152,35 +70,14 @@ jobs:
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:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
#
# - name: Upload macos 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 }}-macOS.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
build-container:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -188,28 +85,19 @@ jobs:
- 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: |
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}.tar.gz
mkdir -p build/install/
mv ./signal-cli-"${GITHUB_REF_NAME#v}"/ build/install/signal-cli
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }}
containerfiles:
./Containerfile
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: ./Containerfile
oci: true
- name: Push To GHCR
@ -227,10 +115,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-native:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -238,26 +125,20 @@ jobs:
- 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: |
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/
chmod +x ./signal-cli-native/signal-cli
mv ./signal-cli-native/signal-cli build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/
chmod +x build/native/nativeCompile/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native
containerfiles:
./native.Containerfile
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: ./native.Containerfile
oci: true
- name: Push To GHCR
@ -275,10 +156,9 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
@ -286,26 +166,20 @@ jobs:
- 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: |
tar xf ./signal-cli-reproducible-build/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
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/
mv signal-cli-client client/target/release/
chmod +x client/target/release/signal-cli-client
- 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
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true
- name: Push To GHCR

7
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/
.kotlin/
.idea/*
!.idea/codeStyles/
build/
@ -13,3 +14,9 @@ out/
.DS_Store
/bin/
/test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5

View File

@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
## [0.14.2] - 2026-04-04
### Added
- Add `--voice-note` parameter to `send` command (Thanks @Kevin)
- Add experimental support for voice calling (Thanks @visigoth)
### Fixed
- Fix `updateGroup` command for adding admins and removing members (Thanks @joeykrim)
## [0.14.1] - 2026-03-08

View File

@ -3,12 +3,12 @@ plugins {
application
eclipse
`check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.5"
id("org.graalvm.buildtools.native") version "1.0.0"
}
allprojects {
group = "org.asamk"
version = "0.14.2-SNAPSHOT"
version = "0.14.2"
}
java {
@ -146,3 +146,17 @@ tasks.register("fatJar", type = Jar::class) {
}
with(tasks.jar.get())
}
tasks.register("writeLibsignalVersion") {
doLast {
val resolutionResult = configurations.runtimeClasspath.get().incoming.resolutionResult
val libsignalDep =
resolutionResult.allDependencies.find { dep -> dep.requested is ModuleComponentSelector && (dep.requested as ModuleComponentSelector).group == "org.signal" && (dep.requested as ModuleComponentSelector).moduleIdentifier.name == "libsignal-client" }
if (libsignalDep != null) {
val version = (libsignalDep.requested as ModuleComponentSelector).version
file("libsignal-version").writeText(version + "\n")
} else {
throw GradleException("Could not find libsignal-client dependency")
}
}
}

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.2" date="2026-04-04">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.2</url>
</release>
<release version="0.14.1" date="2026-03-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.1</url>
</release>

View File

@ -61,13 +61,13 @@ The first line written to the tunnel's stdin:
}
```
| Field | Type | Description |
|-------|------|-------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
| Field | Type | Description |
|----------------------|-------------------------|-----------------------------------------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
If `input_device_name` or `output_device_name` are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
@ -84,33 +84,39 @@ lines are control messages.
### signal-cli -> Tunnel (stdin)
| Type | When | Fields |
|------|------|--------|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) |
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
| Type | When | Fields |
|----------------------|----------------------------|---------------------------------------------------------------------------------------------------|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) |
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
### Tunnel -> signal-cli (stdout)
| Type | When | Fields |
|------|------|--------|
| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` |
| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` |
| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` |
| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) |
| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` |
| `sendBusy` | Line is busy | `callId` |
| `stateChange` | Call state transition | `state`, `reason` (optional) |
| `error` | Something went wrong | `message` |
| 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"}
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
```
---
@ -191,7 +197,6 @@ signal-cli signal-call-tunnel Remote Phone
### JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
It never touches the control socket directly.
**Important:** Call event notifications are not sent by default. Clients must
call `subscribeCallEvents` before initiating or receiving calls. Without this,

View File

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

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@ -4,6 +4,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
@ -37,11 +39,13 @@ import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -64,10 +68,6 @@ 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) {
@ -425,17 +425,32 @@ public interface Manager extends Closeable {
void hangupCall(long callId) throws IOException;
void rejectCall(long callId) throws IOException;
SendMessageResult rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) throws IOException, UnregisteredRecipientException;
void sendCallOffer(
RecipientIdentifier.Single recipient,
CallOffer offer
) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answerOpaque) 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 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 sendHangup(
RecipientIdentifier.Single recipient,
long callId,
MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;

View File

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

View File

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

View File

@ -268,19 +268,19 @@ public record MessageEnvelope(
quote.getMentions() == null
? List.of()
: quote.getMentions()
.stream()
.map(m -> Mention.from(m, recipientResolver, addressResolver))
.toList(),
.stream()
.map(m -> Mention.from(m, recipientResolver, addressResolver))
.toList(),
quote.getAttachments() == null
? List.of()
: quote.getAttachments().stream().map(a -> Attachment.from(a, fileProvider)).toList(),
quote.getBodyRanges() == null
? List.of()
: quote.getBodyRanges()
.stream()
.filter(r -> r.style != null)
.map(TextStyle::from)
.toList());
.stream()
.filter(r -> r.style != null)
.map(TextStyle::from)
.toList());
}
}
@ -599,7 +599,7 @@ public record MessageEnvelope(
Boolean.TRUE.equals(pinnedMessage.getForever())
? -1
: pinnedMessage.getPinDurationInSeconds() == null
? 0
? 0
: pinnedMessage.getPinDurationInSeconds());
}
}
@ -1032,14 +1032,14 @@ public record MessageEnvelope(
final var source = !envelope.isUnidentifiedSender() && serviceId != null
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null
? recipientResolver.resolveRecipient(content.getSender())
? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof ProtocolException e
? recipientResolver.resolveRecipient(e.getSender())
? recipientResolver.resolveRecipient(e.getSender())
: null;
final var sourceDevice = envelope.hasSourceDevice()
? envelope.getSourceDevice()
: content != null
? content.getSenderDevice()
? content.getSenderDevice()
: exception instanceof ProtocolException e ? e.getSenderDevice() : 0;
Optional<Receipt> receipt;

View File

@ -1,3 +1,9 @@
package org.asamk.signal.manager.api;
public record ReceiveConfig(boolean ignoreAttachments, boolean ignoreStories, boolean ignoreAvatars, boolean ignoreStickers, boolean sendReadReceipts) {}
public record ReceiveConfig(
boolean ignoreAttachments,
boolean ignoreStories,
boolean ignoreAvatars,
boolean ignoreStickers,
boolean sendReadReceipts
) {}

View File

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

View File

@ -37,9 +37,7 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
@ -465,7 +463,7 @@ public class AccountHelper {
logger.debug("Attempting to resynchronize username.");
try {
tryReserveConfirmUsername(username);
} catch (UsernameMalformedException | UsernameTakenException | UsernameIsNotReservedException e) {
} catch (NonSuccessfulResponseCodeException e) {
logger.debug("[confirmUsername] Failed to reserve confirm username: {} ({})",
e.getMessage(),
e.getClass().getSimpleName());

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -44,7 +45,10 @@ public class AttachmentHelper {
return attachmentStore.retrieveAttachment(id);
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
try {
@ -65,21 +69,41 @@ public class AttachmentHelper {
return uploadAttachments(attachments, false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(List<String> attachments, boolean voiceNote) throws AttachmentInvalidException, IOException {
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
if (attachments == null) {
return null;
}
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
signalServiceAttachments.add(AttachmentUtils.createAttachmentStream(attachment, voiceNote, uploadSpec));
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
signalServiceAttachments.add(attachmentStream);
}
return signalServiceAttachments;
}
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
final boolean voiceNote
) throws AttachmentInvalidException {
try {
final var streamDetails = Utils.createStreamDetails(attachment);
final var uploadSpec = dependencies.getMessageSender()
.getResumableUploadSpec(streamDetails.first().getLength());
return AttachmentUtils.createAttachmentStream(streamDetails.first(),
streamDetails.second(),
voiceNote,
uploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
var attachmentStream = AttachmentUtils.createAttachmentStream(attachment, uploadSpec);
final var attachmentStream = getAttachmentStream(attachment, false);
return uploadAttachment(attachmentStream);
}

View File

@ -1,30 +1,40 @@
package org.asamk.signal.manager.helper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.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 com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ -32,6 +42,10 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
/**
* Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel
@ -69,7 +83,7 @@ public class CallManager implements AutoCloseable {
}
private void fireCallEvent(CallState state, String reason) {
var callInfo = state.toCallInfo();
var callInfo = state.toCallInfo(account.getRecipientAddressResolver());
for (var listener : callEventListeners) {
try {
listener.handleCallEvent(callInfo, reason);
@ -80,22 +94,16 @@ public class CallManager implements AutoCloseable {
}
public CallInfo startOutgoingCall(
final RecipientIdentifier.Single recipient
) throws IOException, UnregisteredRecipientException {
final RecipientId recipientId
) throws IOException {
var callId = generateCallId();
var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
var recipientAddress = context.getRecipientHelper()
.resolveSignalServiceAddress(recipientId)
.getServiceId();
var recipientApiAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress();
var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
var state = new CallState(callId,
CallInfo.State.RINGING_OUTGOING,
recipientApiAddress,
recipient,
true);
var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientId, null, true);
logger.debug("Starting outgoing call {} to {} (recipientId: {})",
callIdUnsigned(callId),
recipientAddress,
recipientId);
activeCalls.put(callId, state);
fireCallEvent(state, null);
@ -108,7 +116,7 @@ public class CallManager implements AutoCloseable {
// Send createOutgoingCall + proceed via control channel
var createMsg = mapper.createObjectNode();
createMsg.put("type", "createOutgoingCall");
createMsg.put("callId", callIdUnsigned(callId));
createMsg.put("callId", Utils.callIdUnsigned(callId));
createMsg.put("peerId", recipientAddress.toString());
sendControlMessage(state, writeJson(createMsg));
sendProceed(state, callId, turnServers);
@ -116,17 +124,18 @@ public class CallManager implements AutoCloseable {
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.info("Started outgoing call {} to {}", callId, recipient);
return state.toCallInfo();
logger.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress);
return state.toCallInfo(account.getRecipientAddressResolver());
}
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);
}
final var state = getActiveCall(callId);
if (state.state != CallInfo.State.RINGING_INCOMING) {
throw new IOException("Call " + callId + " is not in RINGING_INCOMING state (current: " + state.state + ")");
throw new IOException("Call "
+ callId
+ " is not in RINGING_INCOMING state (current: "
+ state.state
+ ")");
}
// Defer the accept until the tunnel reports Ringing state.
@ -139,46 +148,34 @@ public class CallManager implements AutoCloseable {
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.info("Accepted incoming call {}", callId);
return state.toCallInfo();
logger.debug("Accepted incoming call {}", callIdUnsigned(callId));
return state.toCallInfo(account.getRecipientAddressResolver());
}
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);
}
getActiveCall(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);
}
public SendMessageResult rejectCall(final long callId) throws IOException {
final var callState = getActiveCall(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);
}
final var result = sendBusyMessage(callState.callId, callState.recipientId, callState.deviceId);
endCall(callId, "rejected");
return result;
}
public List<CallInfo> listActiveCalls() {
return activeCalls.values().stream().map(CallState::toCallInfo).toList();
return activeCalls.values()
.stream()
.map((CallState callState) -> callState.toCallInfo(account.getRecipientAddressResolver()))
.toList();
}
public List<TurnServer> getTurnServers() throws IOException {
try {
var result = dependencies.getCallingApi().getTurnServerInfo();
var turnServerList = result.successOrThrow();
var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo());
return turnServerList.stream()
.map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls()))
.toList();
@ -191,47 +188,34 @@ public class CallManager implements AutoCloseable {
// --- Incoming call message handling ---
public void handleIncomingOffer(
final org.asamk.signal.manager.storage.recipients.RecipientId senderId,
final RecipientId recipientId,
final int deviceId,
final long callId,
final MessageEnvelope.Call.Offer.Type type,
final byte[] opaque
) {
if (callEventListeners.isEmpty()) {
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered", callId);
try {
var address = context.getRecipientHelper().resolveSignalServiceAddress(senderId);
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 for unhandled call {}", callId, e);
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered",
callIdUnsigned(callId));
final var result = sendBusyMessage(callId, recipientId, deviceId);
if (!result.isSuccess()) {
logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId));
}
return;
}
var senderAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(senderId)
.resolveRecipientAddress(recipientId)
.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);
var state = new CallState(callId,
CallInfo.State.RINGING_INCOMING,
var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false);
logger.debug("Starting incoming call {} from {} (recipientId: {})",
callIdUnsigned(callId),
senderAddress,
senderIdentifier,
false);
state.rawOfferOpaque = opaque;
recipientId);
activeCalls.put(callId, state);
// Spawn call tunnel binary immediately
@ -239,7 +223,7 @@ public class CallManager implements AutoCloseable {
// 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[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Fetch TURN servers
@ -247,16 +231,16 @@ public class CallManager implements AutoCloseable {
try {
turnServers = getTurnServers();
} catch (IOException e) {
logger.warn("Failed to get TURN servers for incoming call {}", callId, e);
logger.warn("Failed to get TURN servers for incoming call {}", callIdUnsigned(callId), e);
turnServers = List.of();
}
// Send receivedOffer to subprocess
var offerMsg = mapper.createObjectNode();
offerMsg.put("type", "receivedOffer");
offerMsg.put("callId", callIdUnsigned(callId));
offerMsg.put("callId", Utils.callIdUnsigned(callId));
offerMsg.put("peerId", senderAddress.toString());
offerMsg.put("senderDeviceId", 1);
offerMsg.put("senderDeviceId", deviceId);
offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
offerMsg.put("age", 0);
offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
@ -271,50 +255,52 @@ public class CallManager implements AutoCloseable {
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.info("Incoming call {} from {}", callId, senderAddress);
logger.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress);
}
public void handleIncomingAnswer(final long callId, final byte[] opaque) {
public void handleIncomingAnswer(final long callId, final int deviceId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.warn("Received answer for unknown call {}", callId);
logger.warn("Received answer for unknown call {}", callIdUnsigned(callId));
return;
}
// Get identity keys
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Forward raw opaque to subprocess
var answerMsg = mapper.createObjectNode();
answerMsg.put("type", "receivedAnswer");
answerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
answerMsg.put("senderDeviceId", 1);
answerMsg.put("senderDeviceId", deviceId);
answerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey));
sendControlMessage(state, writeJson(answerMsg));
state.deviceId = deviceId;
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.info("Received answer for call {}", callId);
logger.debug("Received answer for call {}", callIdUnsigned(callId));
}
public void handleIncomingIceCandidate(final long callId, final byte[] opaque) {
public void handleIncomingIceCandidate(final long callId, final byte[] opaque, final int deviceId) {
var state = activeCalls.get(callId);
if (state == null) {
logger.debug("Received ICE candidate for unknown call {}", callId);
logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId));
return;
}
// Forward to subprocess as receivedIce
var iceMsg = mapper.createObjectNode();
iceMsg.put("type", "receivedIce");
iceMsg.put("senderDeviceId", deviceId);
var candidates = iceMsg.putArray("candidates");
candidates.add(java.util.Base64.getEncoder().encodeToString(opaque));
sendControlMessage(state, writeJson(iceMsg));
logger.debug("Forwarded ICE candidate to tunnel for call {}", callId);
logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId));
}
public void handleIncomingHangup(final long callId) {
@ -333,9 +319,25 @@ public class CallManager implements AutoCloseable {
// --- Internal helpers ---
private CallState getActiveCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callIdUnsigned(callId));
}
return state;
}
private SendMessageResult sendBusyMessage(final long callId, final RecipientId recipientId, final int deviceId) {
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, deviceId);
return context.getSendHelper().sendCallMessage(callMessage, recipientId);
}
private void sendControlMessage(CallState state, String json) {
if (state.controlWriter == null) {
logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json);
logger.debug("Queueing control message for call {} (not yet connected): {}",
callIdUnsigned(state.callId),
json);
state.pendingControlMessages.add(json);
return;
}
@ -345,7 +347,7 @@ public class CallManager implements AutoCloseable {
private void sendProceed(CallState state, long callId, List<TurnServer> turnServers) {
var proceedMsg = mapper.createObjectNode();
proceedMsg.put("type", "proceed");
proceedMsg.put("callId", callIdUnsigned(callId));
proceedMsg.put("callId", Utils.callIdUnsigned(callId));
proceedMsg.put("hideIp", false);
var iceServers = proceedMsg.putArray("iceServers");
for (var ts : turnServers) {
@ -379,8 +381,7 @@ public class CallManager implements AutoCloseable {
stdinStream.flush();
// stdin is the control write channel
state.controlWriter = new PrintWriter(
new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
state.controlWriter = new PrintWriter(new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
// Flush any pending control messages
for (var msg : state.pendingControlMessages) {
@ -392,17 +393,17 @@ public class CallManager implements AutoCloseable {
sendAcceptIfReady(state);
// Read control events from subprocess stdout
Thread.ofVirtual().name("control-read-" + state.callId).start(() -> {
readControlEvents(state, process.getInputStream());
});
Thread.ofVirtual()
.name("control-read-" + callIdUnsigned(state.callId))
.start(() -> readControlEvents(state, process.getInputStream()));
// Drain subprocess stderr to prevent pipe buffer deadlock
Thread.ofVirtual().name("tunnel-stderr-" + state.callId).start(() -> {
try (var reader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
Thread.ofVirtual().name("tunnel-stderr-" + callIdUnsigned(state.callId)).start(() -> {
try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logger.debug("[tunnel-{}] {}", state.callId, line);
logger.debug("[tunnel-{}] {}", callIdUnsigned(state.callId), line);
}
} catch (IOException ignored) {
}
@ -410,15 +411,15 @@ public class CallManager implements AutoCloseable {
// Monitor process exit
process.onExit().thenAcceptAsync(p -> {
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue());
logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue());
if (activeCalls.containsKey(state.callId)) {
endCall(state.callId, "tunnel_exit");
}
});
logger.info("Spawned signal-call-tunnel for call {}", state.callId);
logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId));
} catch (Exception e) {
logger.error("Failed to spawn tunnel for call {}", state.callId, e);
logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(state.callId), e);
endCall(state.callId, "tunnel_spawn_error");
}
}
@ -461,20 +462,19 @@ public class CallManager implements AutoCloseable {
private String buildConfig(CallState state) {
var config = mapper.createObjectNode();
config.put("call_id", callIdUnsigned(state.callId));
config.put("call_id", Utils.callIdUnsigned(state.callId));
config.put("is_outgoing", state.isOutgoing);
config.put("local_device_id", 1);
return writeJson(config);
}
private void readControlEvents(CallState state, java.io.InputStream inputStream) {
try (var reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
logger.debug("Control event for call {}: {}", state.callId, line);
logger.debug("Control event for call {}: {}", callIdUnsigned(state.callId), line);
try {
var json = mapper.readTree(line);
@ -489,17 +489,19 @@ public class CallManager implements AutoCloseable {
state.outputDeviceName = json.get("outputDeviceName").asText();
}
logger.debug("Tunnel ready for call {}: input={}, output={}",
state.callId, state.inputDeviceName, state.outputDeviceName);
callIdUnsigned(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);
logSendMessageResult(sendOfferViaSignal(state, opaque));
}
case "sendAnswer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
sendAnswerViaSignal(state, opaque);
logSendMessageResult(sendAnswerViaSignal(state, opaque));
}
case "sendIce" -> {
var candidatesArr = json.get("candidates");
@ -507,22 +509,24 @@ public class CallManager implements AutoCloseable {
for (var c : candidatesArr) {
opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText()));
}
sendIceViaSignal(state, opaqueList);
logSendMessageResult(sendIceViaSignal(state, opaqueList));
}
case "sendHangup" -> {
// RingRTC wants us to send a hangup message via Signal protocol.
// This is NOT a local state change local state is handled by stateChange events.
var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal";
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);
logSendMessageResult(sendHangupViaSignal(state, hangupType));
}
}
case "sendBusy" -> {
sendBusyViaSignal(state);
logSendMessageResult(sendBusyViaSignal(state));
}
case "stateChange" -> {
var ringrtcState = json.get("state").asText();
@ -531,19 +535,23 @@ public class CallManager implements AutoCloseable {
}
case "error" -> {
var message = json.has("message") ? json.get("message").asText("unknown") : "unknown";
logger.error("Tunnel error for call {}: {}", state.callId, message);
logger.error("Tunnel error for call {}: {}", callIdUnsigned(state.callId), message);
endCall(state.callId, "tunnel_error");
}
default -> {
logger.debug("Unknown control event type '{}' for call {}", type, state.callId);
logger.debug("Unknown control event type '{}' for call {}",
type,
callIdUnsigned(state.callId));
}
}
} catch (Exception e) {
logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage());
logger.warn("Failed to parse control event JSON for call {}: {}",
callIdUnsigned(state.callId),
e.getMessage());
}
}
} catch (IOException e) {
logger.debug("Control read ended for call {}: {}", state.callId, e.getMessage());
logger.debug("Control read ended for call {}: {}", callIdUnsigned(state.callId), e.getMessage());
}
}
@ -573,114 +581,97 @@ public class CallManager implements AutoCloseable {
fireCallEvent(state, reason);
}
public static void logSendMessageResult(SendMessageResult result) {
var identifier = result.getAddress().getIdentifier();
if (result.getProofRequiredFailure() != null) {
final var failure = result.getProofRequiredFailure();
logger.warn(
"CAPTCHA proof required for sending to \"{}\", available options \"{}\" with challenge token \"{}\", or wait \"{}\" seconds.\n",
identifier,
failure.getOptions()
.stream()
.map(ProofRequiredException.Option::toString)
.collect(Collectors.joining(", ")),
failure.getToken(),
failure.getRetryAfterSeconds());
} else if (result.isNetworkFailure()) {
logger.warn("Network failure for \"{}\"", identifier);
} else if (result.getRateLimitFailure() != null) {
logger.warn("Rate limit failure for \"{}\"", identifier);
} else if (result.isUnregisteredFailure()) {
logger.warn("Unregistered user \"{}\"", identifier);
} else if (result.getIdentityFailure() != null) {
logger.warn("Untrusted Identity for \"{}\"", identifier);
}
}
private void sendAcceptIfReady(CallState state) {
if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) {
state.acceptPending = false;
logger.debug("Sending deferred accept for call {}", state.callId);
logger.debug("Sending deferred accept for call {}", callIdUnsigned(state.callId));
var acceptMsg = mapper.createObjectNode();
acceptMsg.put("type", "accept");
state.controlWriter.println(writeJson(acceptMsg));
}
}
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 SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) {
var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque);
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private 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 SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) {
var answerMessage = new AnswerMessage(state.callId, opaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private 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 SendMessageResult sendIceViaSignal(CallState state, List<byte[]> opaqueList) {
var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId));
return result;
}
private 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 SendMessageResult sendBusyViaSignal(CallState state) {
var busyMessage = new BusyMessage(state.callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId);
return context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
}
private 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 SendMessageResult sendHangupViaSignal(CallState state, String hangupType) {
var type = switch (hangupType) {
case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED;
case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED;
case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY;
default -> HangupMessage.Type.NORMAL;
};
var hangupMessage = new HangupMessage(state.callId, type, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId));
return result;
}
private byte[] getRemoteIdentityKey(CallState state) {
try {
var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier);
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId);
var serviceId = address.getServiceId();
var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId);
if (identityInfo != null) {
return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize());
return getRawIdentityKeyBytes(identityInfo.getIdentityKey());
}
} catch (Exception e) {
logger.warn("Failed to get remote identity key for call {}", state.callId, e);
logger.warn("Failed to get remote identity key for call {}", callIdUnsigned(state.callId), e);
}
logger.warn("Using local identity key as fallback for remote identity key");
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize());
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
}
/**
@ -688,18 +679,18 @@ public class CallManager implements AutoCloseable {
* raw 32-byte Curve25519 public key. Signal Android does this via
* WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC.
*/
private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) {
private static byte[] getRawIdentityKeyBytes(IdentityKey identityKey) {
var serializedKey = identityKey.serialize();
return getRawIdentityKeyBytes(serializedKey);
}
private static byte[] getRawIdentityKeyBytes(final byte[] serializedKey) {
if (serializedKey.length == 33 && serializedKey[0] == 0x05) {
return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length);
}
return serializedKey;
}
/** Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64). */
private static BigInteger callIdUnsigned(long callId) {
return new BigInteger(Long.toUnsignedString(callId));
}
private static String writeJson(ObjectNode node) {
try {
return mapper.writeValueAsString(node);
@ -714,21 +705,19 @@ public class CallManager implements AutoCloseable {
state.state = CallInfo.State.ENDED;
fireCallEvent(state, reason);
logger.info("Call {} ended: {}", callId, reason);
logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason);
// Send Signal protocol hangup to remote peer (unless they initiated the end)
if (!"remote_hangup".equals(reason) && !"rejected".equals(reason) && !"remote_busy".equals(reason)
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);
var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
if (!result.isSuccess()) {
logger.warn("Failed to send hangup to remote for call {}", callIdUnsigned(callId));
logSendMessageResult(result);
}
}
@ -755,13 +744,13 @@ public class CallManager implements AutoCloseable {
if (state == null) return;
if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) {
logger.info("Call {} ring timeout", callId);
logger.debug("Call {} ring timeout", callIdUnsigned(callId));
endCall(callId, "ring_timeout");
}
}
private static long generateCallId() {
return new SecureRandom().nextLong() & Long.MAX_VALUE;
return new BigInteger(64, new SecureRandom()).longValue();
}
@Override
@ -770,6 +759,9 @@ public class CallManager implements AutoCloseable {
for (var callId : new ArrayList<>(activeCalls.keySet())) {
endCall(callId, "shutdown");
}
synchronized (callEventListeners) {
callEventListeners.clear();
}
}
// --- Internal call state tracking ---
@ -778,17 +770,15 @@ public class CallManager implements AutoCloseable {
final long callId;
volatile CallInfo.State state;
final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
final RecipientIdentifier.Single recipientIdentifier;
final RecipientId recipientId;
volatile Integer deviceId;
final boolean isOutgoing;
volatile String inputDeviceName;
volatile String outputDeviceName;
volatile Process tunnelProcess;
volatile PrintWriter controlWriter;
// Raw offer opaque for incoming calls (forwarded to subprocess)
volatile byte[] rawOfferOpaque;
// Control messages queued before the tunnel process starts
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
final List<String> pendingControlMessages = Collections.synchronizedList(new ArrayList<>());
// Accept deferred until tunnel reports Ringing state
volatile boolean acceptPending = false;
// True once the tunnel has reported "Ringing" (ready to accept)
@ -797,19 +787,24 @@ public class CallManager implements AutoCloseable {
CallState(
long callId,
CallInfo.State state,
org.asamk.signal.manager.api.RecipientAddress recipientAddress,
RecipientIdentifier.Single recipientIdentifier,
RecipientId recipientId,
final Integer deviceId,
boolean isOutgoing
) {
this.callId = callId;
this.state = state;
this.recipientAddress = recipientAddress;
this.recipientIdentifier = recipientIdentifier;
this.recipientId = recipientId;
this.deviceId = deviceId;
this.isOutgoing = isOutgoing;
}
CallInfo toCallInfo() {
return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing);
CallInfo toCallInfo(RecipientAddressResolver addressResolver) {
return new CallInfo(callId,
state,
addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(),
inputDeviceName,
outputDeviceName,
isOutgoing);
}
}
}

View File

@ -55,7 +55,7 @@ public class ContactHelper {
final var version = contact == null
? 1
: contact.messageExpirationTimeVersion() == Integer.MAX_VALUE
? Integer.MAX_VALUE
? Integer.MAX_VALUE
: contact.messageExpirationTimeVersion() + 1;
account.getContactStore()
.storeContact(recipientId,

View File

@ -123,7 +123,7 @@ public class GroupHelper {
return Optional.empty();
}
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(streamDetails.getLength());
return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
}
@ -726,7 +726,7 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
final var newMembers = new HashSet<>(members);
newMembers.removeAll(group.getMembers());
newMembers.removeAll(group.getMemberRecipientIds());
newMembers.removeAll(group.getRequestingMembers());
if (!newMembers.isEmpty()) {
var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
@ -768,12 +768,8 @@ public class GroupHelper {
newAdmins.retainAll(group.getMemberRecipientIds());
newAdmins.removeAll(group.getAdminMemberRecipientIds());
if (!newAdmins.isEmpty()) {
for (var admin : newAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, newAdmins, true);
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
}
@ -781,12 +777,8 @@ public class GroupHelper {
final var existingRemoveAdmins = new HashSet<>(removeAdmins);
existingRemoveAdmins.retainAll(group.getAdminMemberRecipientIds());
if (!existingRemoveAdmins.isEmpty()) {
for (var admin : existingRemoveAdmins) {
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, existingRemoveAdmins, false);
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
}

View File

@ -501,18 +501,25 @@ class GroupV2Helper {
Pair<DecryptedGroup, GroupChangeResponse> setMemberAdmin(
GroupInfoV2 groupInfoV2,
RecipientId recipientId,
Set<RecipientId> recipientIds,
boolean admin
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
if (address.getServiceId() instanceof ACI aci) {
final var change = groupOperations.createChangeMemberRole(aci, newRole);
return commitChange(groupInfoV2, change);
} else {
final var change = new GroupChange.Actions.Builder();
final var memberRoles = recipientIds.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
.map(SignalServiceAddress::getServiceId)
.filter(m -> m instanceof ACI)
.map(m -> (ACI) m)
.map(aci -> new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(groupOperations.encryptServiceId(
aci)).role(newRole).build())
.toList();
if (memberRoles.size() < recipientIds.size()) {
throw new IllegalArgumentException("Can't make a PNI a group admin.");
}
change.modifyMemberRoles(memberRoles);
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> setMessageExpirationTimer(

View File

@ -64,6 +64,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType;
@ -402,31 +403,37 @@ public final class IncomingMessageHandler {
}
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender);
handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId);
}
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
final SignalServiceCallMessage callMessage,
final RecipientId sender,
final int deviceId
) {
var callManager = context.getCallManager();
if (callMessage.getDestinationDeviceId().isPresent()
&& callMessage.getDestinationDeviceId().get() != account.getDeviceId()) {
return;
}
callMessage.getOfferMessage().ifPresent(offer -> {
var type = offer.getType() == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL
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());
callManager.handleIncomingOffer(sender, deviceId, offer.getId(), type, offer.getOpaque());
});
callMessage.getAnswerMessage().ifPresent(answer ->
callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque()));
callMessage.getAnswerMessage()
.ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque()));
callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> {
for (var ice : iceUpdates) {
callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque());
callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque(), deviceId);
}
});
@ -440,8 +447,7 @@ public final class IncomingMessageHandler {
}
});
callMessage.getBusyMessage().ifPresent(busy ->
callManager.handleIncomingBusy(busy.getId()));
callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId()));
}
private boolean handlePniSignatureMessage(
@ -655,10 +661,6 @@ public final class IncomingMessageHandler {
final var aep = keysMessage.getAccountEntropyPool();
account.setAccountEntropyPool(aep);
actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getMaster() != null) {
final var masterKey = keysMessage.getMaster();
account.setMasterKey(masterKey);
actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getStorageService() != null) {
final var storageKey = keysMessage.getStorageService();
account.setStorageKey(storageKey);

View File

@ -191,9 +191,11 @@ public final class ProfileHelper {
if (uploadProfile) {
final var streamDetails = avatar != null && avatar.isPresent()
? Utils.createStreamDetails(avatar.get())
.first()
: forceUploadAvatar && avatar == null ? context.getAvatarStore()
.retrieveProfileAvatar(account.getSelfRecipientAddress()) : null;
.first()
: forceUploadAvatar && avatar == null
? context.getAvatarStore()
.retrieveProfileAvatar(account.getSelfRecipientAddress())
: null;
try (streamDetails) {
final var avatarUploadParams = streamDetails != null
? AvatarUploadParams.forAvatar(streamDetails)

View File

@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.DistributionId;
@ -309,6 +310,26 @@ public class SendHelper {
return result;
}
public SendMessageResult sendCallMessage(
final SignalServiceCallMessage callMessage,
final RecipientId recipientId
) {
final var messageSendLogStore = account.getMessageSendLogStore();
final var result = handleSendMessage(recipientId,
(messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendCallMessage(
address,
unidentifiedAccess,
callMessage));
if (callMessage.getTimestamp().isPresent()) {
messageSendLogStore.insertIfPossible(callMessage.getTimestamp().get(),
result,
ContentHint.IMPLICIT,
callMessage.isUrgent());
}
handleSendMessageResult(result);
return result;
}
private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,
@ -503,11 +524,11 @@ public class SendHelper {
Set<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null
? Set.of()
: recipientIds.stream()
.filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s),
unidentifiedAccessesMap.get(s),
groupSendEndorsements))
.collect(Collectors.toSet());
.filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s),
unidentifiedAccessesMap.get(s),
groupSendEndorsements))
.collect(Collectors.toSet());
if (senderKeyTargets.size() < 2) {
logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size());
senderKeyTargets = Set.of();
@ -569,11 +590,11 @@ public class SendHelper {
final var expirationMs = Instant.ofEpochMilli(groupSendEndorsementsExpirationMs);
final var groupSendTokens = groupSendEndorsements != null && groupSecretParams != null
? legacyTargets.stream()
.map(groupSendEndorsements::get)
.map(endorsement -> Optional.ofNullable(endorsement)
.map(e -> e.toFullToken(groupSecretParams, expirationMs))
.orElse(null))
.toList()
.map(groupSendEndorsements::get)
.map(endorsement -> Optional.ofNullable(endorsement)
.map(e -> e.toFullToken(groupSecretParams, expirationMs))
.orElse(null))
.toList()
: null;
final var sealedSenderAccesses = SealedSenderAccess.forFanOutGroupSend(groupSendTokens,
senderCertificate,

View File

@ -131,7 +131,9 @@ public class SyncHelper {
if (groupsFile.exists() && groupsFile.length() > 0) {
try (var groupsFileStream = new FileInputStream(groupsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec();
final var uploadSpec = context.getDependencies()
.getMessageSender()
.getResumableUploadSpec(groupsFile.length());
var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(groupsFileStream)
.withContentType(MimeUtils.OCTET_STREAM)
@ -190,7 +192,9 @@ public class SyncHelper {
if (contactsFile.exists() && contactsFile.length() > 0) {
try (var contactsFileStream = new FileInputStream(contactsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec();
final var uploadSpec = context.getDependencies()
.getMessageSender()
.getResumableUploadSpec(contactsFile.length());
var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(contactsFileStream)
.withContentType(MimeUtils.OCTET_STREAM)
@ -258,7 +262,6 @@ public class SyncHelper {
public SendMessageResult sendKeysMessage() {
var keysMessage = new KeysMessage(account.getOrCreateStorageKey(),
account.getOrCreatePinMasterKey(),
account.getOrCreateAccountEntropyPool(),
account.getOrCreateMediaRootBackupKey());
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage));

View File

@ -21,7 +21,6 @@ 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;
@ -65,6 +64,7 @@ import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TextStyle;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -117,8 +117,7 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Util;
@ -172,7 +171,6 @@ 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();
@ -290,7 +288,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UserStatus(number.isEmpty() ? null : number,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
@ -317,7 +315,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UsernameStatus(username,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
@ -412,10 +410,8 @@ public class ManagerImpl implements Manager {
} else {
context.getAccountHelper().reserveUsernameFromNickname(username);
}
} catch (UsernameMalformedException e) {
throw new InvalidUsernameException("Username is malformed", e);
} catch (UsernameTakenException e) {
throw new InvalidUsernameException("Username is already registered", e);
} catch (NonSuccessfulResponseCodeException e) {
throw new InvalidUsernameException("Username is malformed or already taken", e);
} catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
}
@ -698,7 +694,10 @@ public class ManagerImpl implements Manager {
)) {
final var result = notifySelf
? context.getSendHelper()
.sendMessage(messageBuilder, account.getSelfRecipientId(), editTargetTimestamp, urgent)
.sendMessage(messageBuilder,
account.getSelfRecipientId(),
editTargetTimestamp,
urgent)
: context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
results.put(recipient, List.of(toSendMessageResult(result)));
} else if (recipient instanceof RecipientIdentifier.Single single) {
@ -711,9 +710,9 @@ public class ManagerImpl implements Manager {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
} else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSendHelper()
.sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp, urgent);
.sendAsGroupMessage(messageBuilder, groupId, notifySelf, editTargetTimestamp, urgent);
results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
}
}
@ -837,10 +836,11 @@ public class ManagerImpl implements Manager {
final var remainder = result.getSecond();
if (remainder != null) {
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT,
messageBytes.length);
final var uploadSpec = dependencies.getMessageSender()
.getResumableUploadSpec(streamDetails.getLength());
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
@ -853,7 +853,8 @@ public class ManagerImpl implements Manager {
messageBuilder.withBody(message.messageText());
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments(), message.voiceNote());
final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);
@ -907,7 +908,7 @@ public class ManagerImpl implements Manager {
if (streamDetails == null) {
throw new InvalidStickerException("Missing local sticker file");
}
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(streamDetails.getLength());
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
@ -921,7 +922,7 @@ public class ManagerImpl implements Manager {
final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
for (final var p : message.previews()) {
final var image = p.image().isPresent() ? context.getAttachmentHelper()
.uploadAttachment(p.image().get()) : null;
.uploadAttachment(p.image().get()) : null;
previews.add(new SignalServicePreview(p.url(),
p.title(),
p.description(),
@ -959,12 +960,10 @@ public class ManagerImpl implements Manager {
var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid u) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid()));
} else if (recipient instanceof RecipientIdentifier.Pni pni) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) {
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
} else if (recipient instanceof RecipientIdentifier.Pni(var pni)) {
account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
} else if (recipient instanceof RecipientIdentifier.Single r) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@ -975,8 +974,8 @@ public class ManagerImpl implements Manager {
}
} catch (UnregisteredRecipientException ignored) {
}
} else if (recipient instanceof RecipientIdentifier.Group r) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId());
} else if (recipient instanceof RecipientIdentifier.Group(var groupId)) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId);
}
}
return sendMessage(messageBuilder, recipients, false);
@ -1149,8 +1148,8 @@ public class ManagerImpl implements Manager {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId());
} else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId);
results.put(recipient, List.of(toSendMessageResult(result)));
}
}
@ -1164,7 +1163,7 @@ public class ManagerImpl implements Manager {
final List<String> options,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate);
return sendMessage(messageBuilder, recipients, notifySelf);
@ -1196,7 +1195,7 @@ public class ManagerImpl implements Manager {
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate);
return sendMessage(messageBuilder, recipients, notifySelf);
@ -1716,17 +1715,11 @@ 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);
}
@ -1791,7 +1784,8 @@ public class ManagerImpl implements Manager {
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
return context.getCallManager().startOutgoingCall(recipient);
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
return context.getCallManager().startOutgoingCall(recipientId);
}
@Override
@ -1805,8 +1799,9 @@ public class ManagerImpl implements Manager {
}
@Override
public void rejectCall(final long callId) throws IOException {
context.getCallManager().rejectCall(callId);
public SendMessageResult rejectCall(final long callId) throws IOException {
final var result = context.getCallManager().rejectCall(callId);
return toSendMessageResult(result);
}
@Override
@ -1857,9 +1852,7 @@ public class ManagerImpl implements Manager {
) 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 iceUpdates = iceCandidates.stream().map(opaque -> new IceUpdateMessage(callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
@ -1925,12 +1918,6 @@ public class ManagerImpl implements Manager {
if (thread != null) {
stopReceiveThread(thread);
}
synchronized (callEventListeners) {
for (var listener : callEventListeners) {
context.getCallManager().removeCallEventListener(listener);
}
callEventListeners.clear();
}
context.close();
executor.close();

View File

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

View File

@ -45,6 +45,7 @@ import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.MustRequestNewCodeException;
import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
@ -262,6 +263,8 @@ public class RegistrationManagerImpl implements RegistrationManager {
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
try {
handleResponseException(registrationApi.verifyAccount(sessionId, verificationCode));
} catch (MustRequestNewCodeException e) {
throw new IOException("Verification code expired, please request a new one by registering again.", e);
} catch (AlreadyVerifiedException e) {
// Already verified so can continue registering
}

View File

@ -292,7 +292,6 @@ public class SignalAccount implements Closeable {
final IdentityKeyPair aciIdentity,
final IdentityKeyPair pniIdentity,
final ProfileKey profileKey,
final MasterKey masterKey,
final AccountEntropyPool accountEntropyPool,
final MediaRootBackupKey mediaRootBackupKey
) {
@ -314,7 +313,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
} else {
this.pinMasterKey = masterKey;
this.pinMasterKey = null;
this.accountEntropyPool = null;
}
this.mediaRootBackupKey = mediaRootBackupKey;
@ -942,7 +941,7 @@ public class SignalAccount implements Closeable {
profile.isUnrestrictedUnidentifiedAccess()
? Profile.UnidentifiedAccessMode.UNRESTRICTED
: profile.getUnidentifiedAccess() != null
? Profile.UnidentifiedAccessMode.ENABLED
? Profile.UnidentifiedAccessMode.ENABLED
: Profile.UnidentifiedAccessMode.DISABLED,
capabilities,
null);

View File

@ -152,6 +152,7 @@ public class GroupStore {
statement.setBytes(2, groupId.serialize());
final var result = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (result.isEmpty()) {
connection.commit();
return;
}
internalId = result.get();
@ -876,9 +877,9 @@ public class GroupStore {
final var members = membersString == null
? Set.<RecipientId>of()
: Arrays.stream(membersString.split(","))
.map(Integer::valueOf)
.map(recipientIdCreator::create)
.collect(Collectors.toSet());
.map(Integer::valueOf)
.map(recipientIdCreator::create)
.collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked");
final var archived = resultSet.getBoolean("archived");

View File

@ -115,7 +115,7 @@ public class LegacyJsonIdentityKeyStore {
var trustLevel = trustedKey.hasNonNull("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
var added = trustedKey.hasNonNull("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
.asLong()) : new Date();
.asLong()) : new Date();
identities.add(new LegacyIdentityInfo(address, id, trustLevel, added));
} catch (InvalidKeyException e) {
logger.warn("Error while decoding key for {}: {}", trustedKeyName, e.getMessage());

View File

@ -28,6 +28,7 @@ import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -50,7 +51,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private final Map<Long, Long> recipientsMerged = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = new HashMap<>();
private final Map<ServiceId, RecipientWithAddress> recipientAddressCache = Collections.synchronizedMap(new HashMap<>());
public static void createSql(Connection connection) throws SQLException {
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
@ -184,12 +185,12 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
@Override
public RecipientId resolveRecipient(final ServiceId serviceId) {
final var recipientWithAddress = recipientAddressCache.get(serviceId);
if (recipientWithAddress != null) {
return recipientWithAddress.id();
}
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
final var recipientWithAddress = recipientAddressCache.get(serviceId);
if (recipientWithAddress != null) {
return recipientWithAddress.id();
}
final var recipientId = resolveRecipientLocked(connection, serviceId);
connection.commit();
return recipientId;
@ -1605,9 +1606,9 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
profileCapabilities == null
? Set.of()
: Arrays.stream(profileCapabilities.split(","))
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()),
.map(Profile.Capability::valueOfOrNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet()),
PhoneNumberSharingMode.valueOfOrNull(resultSet.getString("profile_phone_number_sharing")));
}

View File

@ -303,6 +303,7 @@ public class MessageSendLogStore implements AutoCloseable {
}
if (contentId == -1) {
logger.warn("Failed to insert message send log content");
connection.commit();
return -1;
}
insertRecipientsForExistingContent(contentId, recipientDevices, connection);
@ -320,10 +321,10 @@ public class MessageSendLogStore implements AutoCloseable {
return content.dataMessage == null
? null
: content.dataMessage.group != null && content.dataMessage.group.id != null
? content.dataMessage.group.id.toByteArray()
? content.dataMessage.group.id.toByteArray()
: content.dataMessage.groupV2 != null && content.dataMessage.groupV2.masterKey != null
? GroupUtils.getGroupIdV2(new GroupMasterKey(content.dataMessage.groupV2.masterKey.toByteArray()))
.serialize()
? GroupUtils.getGroupIdV2(new GroupMasterKey(content.dataMessage.groupV2.masterKey.toByteArray()))
.serialize()
: null;
} catch (InvalidInputException e) {
logger.warn("Failed to parse groupId id from content");

View File

@ -194,8 +194,9 @@ public class SessionStore implements SignalServiceSessionStore {
if (session != null) {
session.archiveCurrentState();
storeSession(connection, key, session);
connection.commit();
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update session store", e);
}

View File

@ -1,38 +1,15 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
public class AttachmentUtils {
public static SignalServiceAttachmentStream createAttachmentStream(
String attachment,
boolean voiceNote,
ResumableUploadSpec resumableUploadSpec
) throws AttachmentInvalidException {
try {
final var streamDetails = Utils.createStreamDetails(attachment);
return createAttachmentStream(streamDetails.first(), streamDetails.second(), voiceNote, resumableUploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
public static SignalServiceAttachmentStream createAttachmentStream(
String attachment,
ResumableUploadSpec resumableUploadSpec
) throws AttachmentInvalidException {
return createAttachmentStream(attachment, false, resumableUploadSpec);
}
public static SignalServiceAttachmentStream createAttachmentStream(
StreamDetails streamDetails,
Optional<String> name,

View File

@ -78,8 +78,10 @@ public class StickerUtils {
throw new StickerPackInvalidException("Could not find find " + pack.cover().file());
}
var contentType = pack.cover().contentType() != null && !pack.cover().contentType().isEmpty() ? pack.cover()
.contentType() : getContentType(rootPath, zip, pack.cover().file());
var contentType = pack.cover().contentType() != null && !pack.cover().contentType().isEmpty()
? pack.cover()
.contentType()
: getContentType(rootPath, zip, pack.cover().file());
cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
data.second(),
Optional.ofNullable(pack.cover().emoji()).orElse(""),

View File

@ -18,6 +18,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.URI;
@ -235,4 +236,11 @@ public class Utils {
return proxies.getFirst();
}
}
/**
* Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64).
*/
public static BigInteger callIdUnsigned(long callId) {
return new BigInteger(Long.toUnsignedString(callId));
}
}

View File

@ -1,8 +1,8 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.asamk.signal.manager.util.Utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ -29,17 +29,23 @@ class CallManagerTest {
private static final MethodHandle CALL_ID_UNSIGNED;
private static final MethodHandle GENERATE_CALL_ID;
final RecipientAddressResolver recipientAddressResolver = (id) -> new org.asamk.signal.manager.storage.recipients.RecipientAddress(
id.toString());
static {
try {
var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup());
GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, "getRawIdentityKeyBytes",
GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class,
"getRawIdentityKeyBytes",
MethodType.methodType(byte[].class, byte[].class));
CALL_ID_UNSIGNED = lookup.findStatic(CallManager.class, "callIdUnsigned",
CALL_ID_UNSIGNED = lookup.findStatic(Utils.class,
"callIdUnsigned",
MethodType.methodType(BigInteger.class, long.class));
GENERATE_CALL_ID = lookup.findStatic(CallManager.class, "generateCallId",
GENERATE_CALL_ID = lookup.findStatic(CallManager.class,
"generateCallId",
MethodType.methodType(long.class));
} catch (ReflectiveOperationException e) {
@ -62,14 +68,7 @@ class CallManagerTest {
// --- 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
);
return new CallManager.CallState(callId, initialState, TestRecipientId.createTestId(15551234567L), null, true);
}
// ========================================================================
@ -165,14 +164,6 @@ class CallManagerTest {
// 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();
@ -336,11 +327,11 @@ class CallManagerTest {
state.inputDeviceName = "test_input";
state.outputDeviceName = "test_output";
var info = state.toCallInfo();
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(42L, info.callId());
assertEquals(CallInfo.State.CONNECTED, info.state());
assertEquals("+15551234567", info.recipient().number().orElse(null));
assertEquals("RecipientId[id=15551234567]", info.recipient().number().orElse(null));
assertTrue(info.isOutgoing());
assertEquals("test_input", info.inputDeviceName());
assertEquals("test_output", info.outputDeviceName());
@ -350,7 +341,7 @@ class CallManagerTest {
void callState_toCallInfoNullDeviceNames() {
var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING);
var info = state.toCallInfo();
var info = state.toCallInfo(recipientAddressResolver);
assertEquals(CallInfo.State.RINGING_INCOMING, info.state());
assertEquals(null, info.inputDeviceName());

View File

@ -0,0 +1,8 @@
package org.asamk.signal.manager.storage.recipients;
public class TestRecipientId {
public static RecipientId createTestId(long value) {
return new RecipientId(value, null);
}
}

1
libsignal-version Normal file
View File

@ -0,0 +1 @@
0.90.0

View File

@ -14,8 +14,8 @@ all: $(MANPAGESRC)
.PHONY: install
install: all
$(MKDIR) -p man1 man5
for f in *.1; do $(GZIP) < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) < "$$f" > man5/"$$f".gz ; done
for f in *.1; do $(GZIP) -n < "$$f" > man1/"$$f".gz ; done
for f in *.5; do $(GZIP) -n < "$$f" > man5/"$$f".gz ; done
.PHONY: clean
clean:

View File

@ -0,0 +1,34 @@
# Reproducible builds
This process lets you verify that the version of signal-cli that was downloaded from the Github Releases matches the source code in the public repository.
This is achieved by replicating the build environment as Docker images.
Currently, only the following binaries are reproducible:
- [x] JAR package (`signal-cli-XXX.tar.gz`)
- [ ] Native binary (`signal-cli-XXX-Linux-native.tar.gz`)
- [x] Rust client binary (`signal-cli-XXX-Linux-client.tar.gz`)
In the following section, we will use signal-cli version 0.14.2 as the reference example. Simply replace all occurrences of 0.14.2 with the version number you are about to verify.
## Step-by-step instructions
### 0. Prerequisites
Before you begin, ensure you have the following installed:
- git
- docker (or podman)
### 1. Verifying reproducibility
```bash
git clone --depth 1 --branch v0.14.2 https://github.com/AsamK/signal-cli
cd ./signal-cli
./reproducible-builds/verify.sh
```
If each one ends with `... matches!` for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository.
If you get `... doesn't match!`, it means something went wrong (except for the native one for now). Please [open an issue](https://github.com/AsamK/signal-cli/issues/new/choose).

View File

@ -0,0 +1,13 @@
ARG ZULU_TAG="25.0.2-jdk@sha256:9582df6c4415d9c770eb5ff8fce426ebba53631149c9eb083ee126568d32fab3"
FROM docker.io/azul/zulu-openjdk:$ZULU_TAG
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
ARG SNAPSHOT=20260101T000000Z
RUN echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy main" > /etc/apt/sources.list \
&& echo "deb http://snapshot.ubuntu.com/ubuntu/${SNAPSHOT}/ jammy universe" >> /etc/apt/sources.list
RUN apt update && apt install -y make asciidoc-base
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "build" ]

50
reproducible-builds/build.sh Executable file
View File

@ -0,0 +1,50 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/dist"
mkdir -p "$ROOT_DIR/dist"
if command -v podman >/dev/null; then
ENGINE=podman
USER=
else
ENGINE=docker
USER="--user $(id -u):$(id -g)"
fi
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "$VERSION" >dist/VERSION
$ENGINE build -t signal-cli:build ${OVERRIDE_JAVA_VERSION:+--build-arg ZULU_TAG=$OVERRIDE_JAVA_VERSION} -f reproducible-builds/build.Containerfile .
$ENGINE build -t signal-cli:native -f reproducible-builds/native.Containerfile .
$ENGINE build -t signal-cli:client -f reproducible-builds/client.Containerfile .
# Build jar
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:build
mv build/distributions/signal-cli-*.tar.gz dist/
if [ -n "${OVERRIDE_JAVA_VERSION:-}" ]; then
echo -e "\e[33mBuild was performed with overridden Java version $OVERRIDE_JAVA_VERSION, native-image and client will not be built.\e[0m"
exit 0
fi
# Build native-image
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:native
mv build/signal-cli-*-Linux-native.tar.gz dist/
# Build rust client
git clean -Xfd -e '!/dist/' -e '!/dist/**' -e '!/github/' -e '!/github/**'
# shellcheck disable=SC2086
$ENGINE run --pull=never --rm -v "$(pwd)":/signal-cli:Z -e VERSION="$VERSION" $USER signal-cli:client
mv build/signal-cli-*-Linux-client.tar.gz dist/
ls -lsh dist/
echo -e "\e[32mBuild successful!\e[0m"

View File

@ -0,0 +1,7 @@
FROM docker.io/rust:1.94.1-slim-trixie@sha256:c6a474d7164ea2455e09b60a759b1edca38db7373c5689c1dae31780de4e71ac
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "client" ]

View File

@ -0,0 +1,78 @@
#!/bin/bash
set -eu
echo "Build '$1' variant $VERSION ..."
function reset_file_dates() {
find . -exec touch -m -d "@$SOURCE_DATE_EPOCH" {} \;
}
reset_file_dates
if [ "$1" == "build" ]; then
./gradlew build \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
cd man
make install
cd ..
tar_archive="build/distributions/signal-cli-${VERSION}.tar"
tar --transform="flags=r;s|man|signal-cli-${VERSION}/man|" -rf "$tar_archive" man/man{1,5}
# Remake the tarball to ensure reproducible file order and timestamps
mkdir -p build/extracted
tar -xf "$tar_archive" -C build/extracted/
reset_file_dates
rm -f "$tar_archive"
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --transform='s|^\./||' --owner=0 --group=0 --numeric-owner -cf "$tar_archive" -C build/extracted .
gzip -n -9 "$tar_archive"
elif [ "$1" == "native" ]; then
./gradlew nativeCompile \
--no-daemon \
--max-workers=1 \
-Dkotlin.compiler.execution.strategy=in-process \
--no-build-cache \
-Dorg.gradle.caching=false \
-Dgraalvm.native-image.build-time=2026-01-01T00:00:00Z \
-Porg.gradle.java.installations.auto-download=false \
-Porg.gradle.java.installations.auto-detect=false
strip --strip-all \
--remove-section=.note.gnu.build-id \
--remove-section=.comment \
--remove-section=.gnu_debuglink \
--remove-section=.annobin.notes \
--remove-section=.gnu.build.attributes \
--remove-section=.note.ABI-tag \
build/native/nativeCompile/signal-cli
chmod +x build/native/nativeCompile/signal-cli
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-native.tar" -C build/native/nativeCompile signal-cli
gzip -n -9 "build/signal-cli-${VERSION}-Linux-native.tar"
elif [ "$1" == "client" ]; then
cd client
cargo build --release --locked
cd ..
chmod +x client/target/release/signal-cli-client
mkdir -p build
reset_file_dates
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner -cf "build/signal-cli-${VERSION}-Linux-client.tar" -C client/target/release signal-cli-client
gzip -n -9 "build/signal-cli-${VERSION}-Linux-client.tar"
else
echo "Unknown build variant '$1'"
exit 1
fi

View File

@ -0,0 +1,7 @@
FROM container-registry.oracle.com/graalvm/native-image:25.0.2@sha256:4c0d5919f6840d89721274eb8cf81962faa2f870b816967e6732e2a151b150d8
ENV SOURCE_DATE_EPOCH=1767225600
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "native" ]

44
reproducible-builds/verify.sh Executable file
View File

@ -0,0 +1,44 @@
#!/bin/bash
set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../"
cd "$ROOT_DIR"
rm -rf "$ROOT_DIR/github"
mkdir -p "$ROOT_DIR/github"
VERSION=$(sed -n 's/\s*version\s*=\s*"\(.*\)".*/\1/p' build.gradle.kts | tail -n1)
echo "Download latest release from GitHub..."
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz" -o "github/signal-cli-${VERSION}.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" -o "github/signal-cli-${VERSION}-Linux-native.tar.gz"
curl -L --fail "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-client.tar.gz" -o "github/signal-cli-${VERSION}-Linux-client.tar.gz"
./reproducible-builds/build.sh
rm -f {github,dist}/VERSION
echo "commit: $(git rev-parse HEAD)"
echo "sha256 hashes of GitHub release:"
sha256sum github/*
echo "sha256 hashes of locally built files:"
sha256sum dist/*
reproducible=true
for file in $(cd github && find . -type f); do
if diff "github/$file" "dist/$file" >/dev/null 2>&1; then
echo -e "\e[32m[+] '$(basename "$file")' matches!\e[0m"
elif [[ "$file" =~ "native" ]]; then
echo -e "\e[33m[-] '$(basename "$file")' doesn't match! (not supported yet)\e[0m"
reproducible=false
else
echo -e "\e[31m[-] '$(basename "$file")' doesn't match!\e[0m"
reproducible=false
fi
done
if [ "$reproducible" = false ]; then
exit 1
fi

View File

@ -204,9 +204,9 @@ public class App {
private OutputWriter getOutputWriter(final Command command) throws UserErrorException {
final var outputTypeInput = ns.<OutputType>get("output");
final var outputType = outputTypeInput == null ? command.getSupportedOutputTypes()
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
final var writer = new BufferedWriter(new OutputStreamWriter(System.out, IOUtils.getConsoleCharset()));
final var outputWriter = outputType == null
? null

View File

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

View File

@ -15,6 +15,8 @@ import org.slf4j.helpers.MessageFormatter;
import java.util.ArrayList;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final Manager m;
@ -297,26 +299,32 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
}
if (callMessage.answer().isPresent()) {
var answerMessage = callMessage.answer().get();
writer.println("Answer message: {}, opaque length: {})", answerMessage.id(), answerMessage.opaque().length);
writer.println("Answer message: {}, opaque length: {})",
callIdUnsigned(answerMessage.id()),
answerMessage.opaque().length);
}
if (callMessage.busy().isPresent()) {
var busyMessage = callMessage.busy().get();
writer.println("Busy message: {}", busyMessage.id());
writer.println("Busy message: {}", callIdUnsigned(busyMessage.id()));
}
if (callMessage.hangup().isPresent()) {
var hangupMessage = callMessage.hangup().get();
writer.println("Hangup message: {}", hangupMessage.id());
writer.println("Hangup message: {}", callIdUnsigned(hangupMessage.id()));
}
if (!callMessage.iceUpdate().isEmpty()) {
writer.println("Ice update messages:");
var iceUpdateMessages = callMessage.iceUpdate();
for (var iceUpdateMessage : iceUpdateMessages) {
writer.println("- {}, opaque length: {}", iceUpdateMessage.id(), iceUpdateMessage.opaque().length);
writer.println("- {}, opaque length: {}",
callIdUnsigned(iceUpdateMessage.id()),
iceUpdateMessage.opaque().length);
}
}
if (callMessage.offer().isPresent()) {
var offerMessage = callMessage.offer().get();
writer.println("Offer message: {}, opaque length: {}", offerMessage.id(), offerMessage.opaque().length);
writer.println("Offer message: {}, opaque length: {}",
callIdUnsigned(offerMessage.id()),
offerMessage.opaque().length);
}
if (callMessage.opaque().isPresent()) {
final var opaqueMessage = callMessage.opaque().get();
@ -604,8 +612,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Size: {}{}",
attachment.size().isPresent() ? attachment.size().get() + " bytes" : "<unavailable>",
attachment.preview().isPresent() ? " (Preview is available: "
+ attachment.preview().get().length
+ " bytes)" : "");
+ attachment.preview().get().length
+ " bytes)" : "");
}
if (attachment.thumbnail().isPresent()) {
writer.println("Thumbnail:");

View File

@ -23,10 +23,7 @@ public class AcceptCallCommand implements JsonRpcLocalCommand {
@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.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to accept.");
}
@Override

View File

@ -41,6 +41,9 @@ public class GetAttachmentCommand implements JsonRpcLocalCommand {
) throws CommandException {
final var id = ns.getString("id");
if (id == null) {
throw new UserErrorException("Missing attachment id parameter");
}
try (InputStream attachment = m.retrieveAttachment(id)) {
final var bytes = attachment.readAllBytes();

View File

@ -21,10 +21,7 @@ public class HangupCallCommand implements JsonRpcLocalCommand {
@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.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to hang up.");
}
@Override

View File

@ -5,13 +5,10 @@ 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

View File

@ -109,7 +109,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getPhoneNumberSharingMode() == null
? ""
: String.valueOf(r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY),
== PhoneNumberSharingMode.EVERYBODY),
r.getDiscoverable() == null ? "" : String.valueOf(r.getDiscoverable()));
}
}
@ -121,17 +121,17 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
final var jsonInternal = !internal
? null
: new JsonContact.JsonInternal(r.getProfile()
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
r.getProfile().getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNKNOWN
? null
? null
: r.getProfile().getUnidentifiedAccessMode().name(),
r.getProfile().getPhoneNumberSharingMode() == null
? null
? null
: r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY,
== PhoneNumberSharingMode.EVERYBODY,
r.getDiscoverable());
return new JsonContact(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
@ -159,9 +159,9 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getAboutEmoji(),
r.getProfile().getAvatarUrlPath() != null,
r.getProfile().getMobileCoinAddress() == null
? null
? null
: Base64.getEncoder()
.encodeToString(r.getProfile().getMobileCoinAddress())),
.encodeToString(r.getProfile().getMobileCoinAddress())),
jsonInternal);
}).toList();
writer.write(jsonContacts);

View File

@ -21,10 +21,7 @@ public class RejectCallCommand implements JsonRpcLocalCommand {
@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.");
subparser.addArgument("--call-id").type(long.class).required(true).help("The call ID to reject.");
}
@Override

View File

@ -82,7 +82,11 @@ public class SendPollCreateCommand implements JsonRpcLocalCommand {
throw new UserErrorException("Poll options must not be empty");
}
if (option.length() > MAX_POLL_OPTION_LENGTH) {
throw new UserErrorException("Poll option \"" + option + "\" exceeds the maximum length of " + MAX_POLL_OPTION_LENGTH + " characters");
throw new UserErrorException("Poll option \""
+ option
+ "\" exceeds the maximum length of "
+ MAX_POLL_OPTION_LENGTH
+ " characters");
}
}

View File

@ -4,6 +4,8 @@ import org.asamk.Signal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Contact;
@ -37,12 +39,14 @@ import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
@ -925,12 +929,12 @@ public class DbusManagerImpl implements Manager {
// --- 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) {
public CallInfo startCall(final 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) {
public CallInfo acceptCall(final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@ -940,42 +944,54 @@ public class DbusManagerImpl implements Manager {
}
@Override
public void rejectCall(final long callId) {
public SendMessageResult 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() {
public java.util.List<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) {
public void sendCallOffer(final RecipientIdentifier.Single recipient, final CallOffer offer) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendCallAnswer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final byte[] answerOpaque) {
public void sendCallAnswer(
final RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendIceUpdate(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final java.util.List<byte[]> iceCandidates) {
public void sendIceUpdate(
final RecipientIdentifier.Single recipient,
final long callId,
final java.util.List<byte[]> iceCandidates
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendHangup(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final org.asamk.signal.manager.api.MessageEnvelope.Call.Hangup.Type type) {
public void sendHangup(
final RecipientIdentifier.Single recipient,
final long callId,
final MessageEnvelope.Call.Hangup.Type type
) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void sendBusy(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId) {
public void sendBusy(final RecipientIdentifier.Single recipient, final long callId) {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public java.util.List<org.asamk.signal.manager.api.TurnServer> getTurnServerInfo() {
public java.util.List<TurnServer> getTurnServerInfo() {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}

View File

@ -18,15 +18,13 @@ public record JsonCallEvent(
) {
public static JsonCallEvent from(CallInfo callInfo, String reason) {
return new JsonCallEvent(
callInfo.callId(),
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
);
reason);
}
}

View File

@ -4,9 +4,12 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.math.BigInteger;
import java.util.Base64;
import java.util.List;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
record JsonCallMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) Answer answerMessage,
@ -23,38 +26,41 @@ record JsonCallMessage(
callMessage.iceUpdate().stream().map(IceUpdate::from).toList());
}
record Offer(long id, String type, String opaque) {
record Offer(BigInteger id, String type, String opaque) {
public static Offer from(final MessageEnvelope.Call.Offer offer) {
return new Offer(offer.id(), offer.type().name(), Base64.getEncoder().encodeToString(offer.opaque()));
return new Offer(callIdUnsigned(offer.id()),
offer.type().name(),
Base64.getEncoder().encodeToString(offer.opaque()));
}
}
public record Answer(long id, String opaque) {
public record Answer(BigInteger id, String opaque) {
public static Answer from(final MessageEnvelope.Call.Answer answer) {
return new Answer(answer.id(), Base64.getEncoder().encodeToString(answer.opaque()));
return new Answer(callIdUnsigned(answer.id()), Base64.getEncoder().encodeToString(answer.opaque()));
}
}
public record Busy(long id) {
public record Busy(BigInteger id) {
public static Busy from(final MessageEnvelope.Call.Busy busy) {
return new Busy(busy.id());
return new Busy(callIdUnsigned(busy.id()));
}
}
public record Hangup(long id, String type, int deviceId) {
public record Hangup(BigInteger id, String type, int deviceId) {
public static Hangup from(final MessageEnvelope.Call.Hangup hangup) {
return new Hangup(hangup.id(), hangup.type().name(), hangup.deviceId());
return new Hangup(callIdUnsigned(hangup.id()), hangup.type().name(), hangup.deviceId());
}
}
public record IceUpdate(long id, String opaque) {
public record IceUpdate(BigInteger id, String opaque) {
public static IceUpdate from(final MessageEnvelope.Call.IceUpdate iceUpdate) {
return new IceUpdate(iceUpdate.id(), Base64.getEncoder().encodeToString(iceUpdate.opaque()));
return new IceUpdate(callIdUnsigned(iceUpdate.id()),
Base64.getEncoder().encodeToString(iceUpdate.opaque()));
}
}
}

View File

@ -36,7 +36,7 @@ record JsonDataMessage(
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
final var timestamp = dataMessage.timestamp();
final var groupInfo = dataMessage.groupContext().isPresent() ? JsonGroupInfo.from(dataMessage.groupContext()
.get(), m) : null;
.get(), m) : null;
final var storyContext = dataMessage.storyContext().isPresent()
? JsonStoryContext.from(dataMessage.storyContext().get())
: null;
@ -48,32 +48,32 @@ record JsonDataMessage(
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
final var mentions = !dataMessage.mentions().isEmpty() ? dataMessage.mentions()
.stream()
.map(JsonMention::from)
.toList() : null;
.stream()
.map(JsonMention::from)
.toList() : null;
final var previews = !dataMessage.previews().isEmpty() ? dataMessage.previews()
.stream()
.map(JsonPreview::from)
.toList() : null;
.stream()
.map(JsonPreview::from)
.toList() : null;
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
: null;
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
.stream()
.map(JsonAttachment::from)
.toList() : null;
.stream()
.map(JsonAttachment::from)
.toList() : null;
final var sticker = dataMessage.sticker().isPresent() ? JsonSticker.from(dataMessage.sticker().get()) : null;
final var contacts = !dataMessage.sharedContacts().isEmpty() ? dataMessage.sharedContacts()
.stream()
.map(JsonSharedContact::from)
.toList() : null;
.stream()
.map(JsonSharedContact::from)
.toList() : null;
final var pollCreate = dataMessage.pollCreate().map(JsonPollCreate::from).orElse(null);
final var pollVote = dataMessage.pollVote().map(JsonPollVote::from).orElse(null);
final var pollTerminate = dataMessage.pollTerminate().map(JsonPollTerminate::from).orElse(null);
final var textStyles = !dataMessage.textStyles().isEmpty() ? dataMessage.textStyles()
.stream()
.map(JsonTextStyle::from)
.toList() : null;
.stream()
.map(JsonTextStyle::from)
.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);

View File

@ -31,14 +31,14 @@ public record JsonQuote(
: null;
final var attachments = !quote.attachments().isEmpty() ? quote.attachments()
.stream()
.map(JsonQuotedAttachment::from)
.toList() : List.<JsonQuotedAttachment>of();
.stream()
.map(JsonQuotedAttachment::from)
.toList() : List.<JsonQuotedAttachment>of();
final var textStyles = !quote.textStyles().isEmpty() ? quote.textStyles()
.stream()
.map(JsonTextStyle::from)
.toList() : null;
.stream()
.map(JsonTextStyle::from)
.toList() : null;
return new JsonQuote(id, author, authorNumber, authorUuid, text, mentions, attachments, textStyles);
}

View File

@ -23,13 +23,13 @@ public record JsonSendMessageResult(
result.isSuccess()
? Type.SUCCESS
: result.isRateLimitFailure()
? Type.RATE_LIMIT_FAILURE
? Type.RATE_LIMIT_FAILURE
: result.isNetworkFailure()
? Type.NETWORK_FAILURE
? Type.NETWORK_FAILURE
: result.isUnregisteredFailure()
? Type.UNREGISTERED_FAILURE
? Type.UNREGISTERED_FAILURE
: result.isInvalidPreKeyFailure()
? Type.INVALID_PRE_KEY_FAILURE
? Type.INVALID_PRE_KEY_FAILURE
: Type.IDENTITY_FAILURE,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getRetryAfterSeconds() : null);

View File

@ -28,9 +28,9 @@ public record JsonSharedContact(
: null;
final var address = !contact.address().isEmpty() ? contact.address()
.stream()
.map(JsonContactAddress::from)
.toList() : null;
.stream()
.map(JsonContactAddress::from)
.toList() : null;
final var organization = contact.organization().orElse(null);

View File

@ -47,9 +47,9 @@ record JsonSyncMessage(
}
final var readMessages = !syncMessage.read().isEmpty() ? syncMessage.read()
.stream()
.map(JsonSyncReadMessage::from)
.toList() : null;
.stream()
.map(JsonSyncReadMessage::from)
.toList() : null;
final JsonSyncMessageType type;
if (syncMessage.contacts().isPresent()) {

View File

@ -14,6 +14,7 @@ import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcSingleCommand;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.json.JsonCallEvent;
import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
@ -24,7 +25,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.channels.ClosedChannelException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -41,7 +42,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 final Map<Integer, List<Pair<Manager, Manager.CallEventListener>>> callEventHandlers = new HashMap<>();
private SignalJsonRpcCommandHandler commandHandler;
public SignalJsonRpcDispatcherHandler(
@ -63,6 +64,10 @@ public class SignalJsonRpcDispatcherHandler {
c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
c.addOnManagerRemovedHandler(this::unsubscribeReceive);
}
c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createReceiveHandler(m, subscriptionId, false))));
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
createCallEventHandler(m, subscriptionId))));
handleConnection();
}
@ -80,47 +85,57 @@ public class SignalJsonRpcDispatcherHandler {
handleConnection();
}
private void subscribeCallEvents(final Manager manager) {
// Prevent duplicate subscriptions for the same manager
if (callEventHandlers.stream().anyMatch(p -> p.first().equals(manager))) {
return;
}
Manager.CallEventListener listener = (callInfo, reason) -> {
private int subscribeCallEvents(final Manager manager) {
return subscribeCallEvents(List.of(manager));
}
private int subscribeCallEvents(final Collection<Manager> managers) {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var listeners = managers.stream().map(m -> createCallEventHandler(m, subscriptionId)).toList();
callEventHandlers.put(subscriptionId, listeners);
return subscriptionId;
}
private Pair<Manager, Manager.CallEventListener> createCallEventHandler(final Manager m, final int subscriptionId) {
final Manager.CallEventListener listener = (callInfo, reason) -> {
final var params = new ObjectNode(objectMapper.getNodeFactory());
params.set("account", params.textNode(manager.getSelfNumber()));
params.set("callEvent", objectMapper.valueToTree(
org.asamk.signal.json.JsonCallEvent.from(callInfo, reason)));
params.set("subscription", IntNode.valueOf(subscriptionId));
params.set("result", objectMapper.valueToTree(JsonCallEvent.from(callInfo, reason)));
final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
logger.debug("Call event channel closed, removing listener");
unsubscribeReceive(subscriptionId);
}
}
};
manager.addCallEventListener(listener);
callEventHandlers.add(new Pair<>(manager, listener));
m.addCallEventListener(listener);
return new Pair<>(m, listener);
}
private void unsubscribeCallEvents(final Manager manager) {
var iterator = callEventHandlers.iterator();
while (iterator.hasNext()) {
var pair = iterator.next();
if (pair.first().equals(manager)) {
pair.first().removeCallEventListener(pair.second());
iterator.remove();
}
private boolean unsubscribeCallEvents(final int subscriptionId) {
final var handlers = callEventHandlers.remove(subscriptionId);
if (handlers == null) {
return false;
}
for (final var pair : handlers) {
unsubscribeCallEventHandler(pair);
}
return true;
}
private void unsubscribeAllCallEvents() {
for (var pair : callEventHandlers) {
pair.first().removeCallEventListener(pair.second());
}
callEventHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeCallEventHandler));
callEventHandlers.clear();
}
private void unsubscribeCallEventHandler(final Pair<Manager, Manager.CallEventListener> pair) {
final var m = pair.first();
final var handler = pair.second();
m.removeCallEventListener(handler);
}
private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0);
private int subscribeReceive(final Manager manager, boolean internalSubscription) {
@ -129,34 +144,42 @@ public class SignalJsonRpcDispatcherHandler {
private int subscribeReceive(final List<Manager> managers, boolean internalSubscription) {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var handlers = managers.stream().map(m -> {
final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> {
ContainerNode<?> params;
if (internalSubscription) {
params = objectMapper.valueToTree(s);
} else {
final var paramsNode = new ObjectNode(objectMapper.getNodeFactory());
paramsNode.set("subscription", IntNode.valueOf(subscriptionId));
paramsNode.set("result", objectMapper.valueToTree(s));
params = paramsNode;
}
final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
unsubscribeReceive(subscriptionId);
}
}
});
m.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m, (Manager.ReceiveMessageHandler) receiveMessageHandler);
}).toList();
final var handlers = managers.stream()
.map(m -> createReceiveHandler(m, subscriptionId, internalSubscription))
.toList();
receiveHandlers.put(subscriptionId, handlers);
return subscriptionId;
}
private Pair<Manager, Manager.ReceiveMessageHandler> createReceiveHandler(
final Manager m,
final int subscriptionId,
final boolean internalSubscription
) {
final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> {
ContainerNode<?> params;
if (internalSubscription) {
params = objectMapper.valueToTree(s);
} else {
final var paramsNode = new ObjectNode(objectMapper.getNodeFactory());
paramsNode.set("subscription", IntNode.valueOf(subscriptionId));
paramsNode.set("result", objectMapper.valueToTree(s));
params = paramsNode;
}
final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null);
try {
jsonRpcSender.sendRequest(jsonRpcRequest);
} catch (AssertionError e) {
if (e.getCause() instanceof ClosedChannelException) {
unsubscribeReceive(subscriptionId);
}
}
});
m.addReceiveHandler(receiveMessageHandler);
return new Pair<>(m, receiveMessageHandler);
}
private boolean unsubscribeReceive(final int subscriptionId) {
final var handlers = receiveHandlers.remove(subscriptionId);
if (handlers == null) {
@ -304,7 +327,8 @@ public class SignalJsonRpcDispatcherHandler {
final Manager m,
final JsonWriter jsonWriter
) throws CommandException {
subscribeCallEvents(m);
final var subscriptionId = subscribeCallEvents(m);
jsonWriter.write(subscriptionId);
}
@Override
@ -313,14 +337,12 @@ public class SignalJsonRpcDispatcherHandler {
final MultiAccountManager c,
final JsonWriter jsonWriter
) throws CommandException {
for (var m : c.getManagers()) {
subscribeCallEvents(m);
}
c.addOnManagerAddedHandler(SignalJsonRpcDispatcherHandler.this::subscribeCallEvents);
final var subscriptionId = subscribeCallEvents(c.getManagers());
jsonWriter.write(subscriptionId);
}
}
private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand<Void>, JsonRpcMultiCommand<Void> {
private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand<JsonNode>, JsonRpcMultiCommand<JsonNode> {
@Override
public String getName() {
@ -328,21 +350,48 @@ public class SignalJsonRpcDispatcherHandler {
}
@Override
public void handleCommand(
final Void request,
final Manager m,
final JsonWriter jsonWriter
) throws CommandException {
unsubscribeCallEvents(m);
public TypeReference<JsonNode> getRequestType() {
return new TypeReference<>() {};
}
@Override
public void handleCommand(
final Void request,
final JsonNode request,
final Manager m,
final JsonWriter jsonWriter
) throws CommandException {
final var subscriptionId = getSubscriptionId(request);
if (subscriptionId == null) {
throw new UserErrorException("Missing subscription parameter with subscription id");
} else {
if (!unsubscribeCallEvents(subscriptionId)) {
throw new UserErrorException("Unknown subscription id");
}
}
}
@Override
public void handleCommand(
final JsonNode request,
final MultiAccountManager c,
final JsonWriter jsonWriter
) throws CommandException {
unsubscribeAllCallEvents();
final var subscriptionId = getSubscriptionId(request);
if (subscriptionId == null) {
throw new UserErrorException("Missing subscription parameter with subscription id");
} else {
if (!unsubscribeCallEvents(subscriptionId)) {
throw new UserErrorException("Unknown subscription id");
}
}
}
private Integer getSubscriptionId(final JsonNode request) {
return switch (request) {
case ArrayNode req -> req.get(0).asInt();
case ObjectNode req -> req.get("subscription").asInt();
case null, default -> null;
};
}
}
}

View File

@ -1082,6 +1082,12 @@
}
]
},
{
"type": "java.math.BigInteger"
},
{
"type": "java.math.BigInteger[]"
},
{
"type": "java.net.NetPermission"
},
@ -1977,10 +1983,18 @@
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
@ -2064,6 +2078,10 @@
"name": "callId",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
@ -2076,6 +2094,10 @@
"name": "number",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
@ -2297,10 +2319,18 @@
"name": "codec",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "mediaSocketPath",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "ptimeMs",
"parameterTypes": []
@ -2421,6 +2451,43 @@
{
"type": "org.asamk.signal.json.JsonAttachment[]"
},
{
"type": "org.asamk.signal.json.JsonCallEvent",
"methods": [
{
"name": "callId",
"parameterTypes": []
},
{
"name": "inputDeviceName",
"parameterTypes": []
},
{
"name": "isOutgoing",
"parameterTypes": []
},
{
"name": "number",
"parameterTypes": []
},
{
"name": "outputDeviceName",
"parameterTypes": []
},
{
"name": "reason",
"parameterTypes": []
},
{
"name": "state",
"parameterTypes": []
},
{
"name": "uuid",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.json.JsonCallMessage",
"allDeclaredFields": true,
@ -7484,6 +7551,35 @@
"allDeclaredFields": true,
"allDeclaredMethods": true
},
{
"type": "org.whispersystems.signalservice.api.messages.calls.TurnServerInfo",
"fields": [
{
"name": "hostname"
},
{
"name": "password"
},
{
"name": "ttl"
},
{
"name": "urls"
},
{
"name": "urlsWithIps"
},
{
"name": "username"
}
],
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo",
"allDeclaredFields": true,
@ -8064,6 +8160,17 @@
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse",
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.util.List"
]
}
]
},
{
"type": "org.whispersystems.signalservice.internal.push.GetUsernameFromLinkResponseBody",
"allDeclaredFields": true,

View File

@ -2,11 +2,8 @@ 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;
@ -17,7 +14,12 @@ 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 callInfo = new CallInfo(123L,
CallInfo.State.CONNECTED,
recipient,
"signal_input_123",
"signal_output_123",
true);
var event = JsonCallEvent.from(callInfo, null);
@ -34,7 +36,12 @@ class JsonCallEventTest {
@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 callInfo = new CallInfo(456L,
CallInfo.State.RINGING_INCOMING,
recipient,
"signal_input_456",
"signal_output_456",
false);
var event = JsonCallEvent.from(callInfo, null);
@ -48,7 +55,12 @@ class JsonCallEventTest {
@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 callInfo = new CallInfo(789L,
CallInfo.State.RINGING_OUTGOING,
recipient,
"signal_input_789",
"signal_output_789",
true);
var event = JsonCallEvent.from(callInfo, null);
@ -81,7 +93,12 @@ class JsonCallEventTest {
@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 callInfo = new CallInfo(200L,
CallInfo.State.CONNECTING,
recipient,
"signal_input_200",
"signal_output_200",
true);
var event = JsonCallEvent.from(callInfo, null);
@ -97,8 +114,17 @@ class JsonCallEventTest {
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"};
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);

View File

@ -2,25 +2,57 @@ package org.asamk.signal.jsonrpc;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.api.*;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.output.JsonWriter;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.time.Duration;
import java.util.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands
@ -32,6 +64,7 @@ class SubscribeCallEventsTest {
* Feeds pre-configured JSON-RPC lines to the handler, then returns null to end.
*/
private static class LineFeeder {
private final Queue<String> lines = new ConcurrentLinkedQueue<>();
void addLine(String line) {
@ -47,6 +80,7 @@ class SubscribeCallEventsTest {
* Captures JSON-RPC responses written by the handler.
*/
private static class CapturingJsonWriter implements JsonWriter {
final List<Object> written = Collections.synchronizedList(new ArrayList<>());
@Override
@ -59,6 +93,7 @@ class SubscribeCallEventsTest {
* Minimal Manager stub that tracks call event listener add/remove calls.
*/
private static class StubManager implements Manager {
final List<CallEventListener> listeners = new ArrayList<>();
final AtomicInteger addCount = new AtomicInteger(0);
final AtomicInteger removeCount = new AtomicInteger(0);
@ -68,113 +103,483 @@ class SubscribeCallEventsTest {
this.selfNumber = selfNumber;
}
@Override public void addCallEventListener(CallEventListener listener) {
@Override
public void addCallEventListener(CallEventListener listener) {
addCount.incrementAndGet();
listeners.add(listener);
}
@Override public void removeCallEventListener(CallEventListener listener) {
@Override
public void removeCallEventListener(CallEventListener listener) {
removeCount.incrementAndGet();
listeners.remove(listener);
}
@Override public String getSelfNumber() { return selfNumber; }
@Override
public String getSelfNumber() {
return selfNumber;
}
// --- Stubs for remaining Manager interface methods ---
@Override public Map<String, UserStatus> getUserStatus(Set<String> n) { return Map.of(); }
@Override public Map<String, UsernameStatus> getUsernameStatus(Set<String> u) { return Map.of(); }
@Override public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {}
@Override public Configuration getConfiguration() { return null; }
@Override public void updateConfiguration(Configuration c) {}
@Override public void updateProfile(UpdateProfile u) {}
@Override public String getUsername() { return null; }
@Override public UsernameLinkUrl getUsernameLink() { return null; }
@Override public void setUsername(String u) {}
@Override public void deleteUsername() {}
@Override public void startChangeNumber(String n, boolean v, String c) {}
@Override public void finishChangeNumber(String n, String v, String p) {}
@Override public void unregister() {}
@Override public void deleteAccount() {}
@Override public void submitRateLimitRecaptchaChallenge(String c, String cap) {}
@Override public List<Device> getLinkedDevices() { return List.of(); }
@Override public void updateLinkedDevice(int d, String n) {}
@Override public void removeLinkedDevices(int d) {}
@Override public void addDeviceLink(DeviceLinkUrl u) {}
@Override public void setRegistrationLockPin(Optional<String> p) {}
@Override public List<Group> getGroups() { return List.of(); }
@Override public List<Group> getGroups(Collection<GroupId> g) { return List.of(); }
@Override public SendGroupMessageResults quitGroup(GroupId g, Set<RecipientIdentifier.Single> a) { return null; }
@Override public void deleteGroup(GroupId g) {}
@Override public Pair<GroupId, SendGroupMessageResults> createGroup(String n, Set<RecipientIdentifier.Single> m, String a) { return null; }
@Override public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) { return null; }
@Override public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl u) { return null; }
@Override public SendMessageResults sendTypingMessage(TypingAction a, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List<Long> m) { return null; }
@Override public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List<Long> m) { return null; }
@Override public SendMessageResults sendMessage(Message m, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendEditMessage(Message m, Set<RecipientIdentifier> r, long t) { return null; }
@Override public SendMessageResults sendRemoteDeleteMessage(long t, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendMessageReaction(String e, boolean rm, RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendAdminDelete(RecipientIdentifier.Single a, long t, Set<RecipientIdentifier.Group> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendPinMessage(int d, RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendUnpinMessage(RecipientIdentifier.Single a, long t, Set<RecipientIdentifier> r, boolean n, boolean s) { return null; }
@Override public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) { return null; }
@Override public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> r) { return null; }
@Override public SendMessageResults sendMessageRequestResponse(MessageEnvelope.Sync.MessageRequestResponse.Type t, Set<RecipientIdentifier> r) { return null; }
@Override public SendMessageResults sendPollCreateMessage(String q, boolean a, List<String> o, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendPollVoteMessage(RecipientIdentifier.Single a, long t, List<Integer> o, int v, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public SendMessageResults sendPollTerminateMessage(long t, Set<RecipientIdentifier> r, boolean n) { return null; }
@Override public void hideRecipient(RecipientIdentifier.Single r) {}
@Override public void deleteRecipient(RecipientIdentifier.Single r) {}
@Override public void deleteContact(RecipientIdentifier.Single r) {}
@Override public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {}
@Override public void setContactsBlocked(Collection<RecipientIdentifier.Single> r, boolean b) {}
@Override public void setGroupsBlocked(Collection<GroupId> g, boolean b) {}
@Override public void setExpirationTimer(RecipientIdentifier.Single r, int t) {}
@Override public StickerPackUrl uploadStickerPack(File p) { return null; }
@Override public void installStickerPack(StickerPackUrl u) {}
@Override public List<StickerPack> getStickerPacks() { return List.of(); }
@Override public void requestAllSyncData() {}
@Override public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {}
@Override public void removeReceiveHandler(ReceiveMessageHandler h) {}
@Override public boolean isReceiving() { return false; }
@Override public void receiveMessages(Optional<Duration> t, Optional<Integer> m, ReceiveMessageHandler h) {}
@Override public void stopReceiveMessages() {}
@Override public void setReceiveConfig(ReceiveConfig r) {}
@Override public boolean isContactBlocked(RecipientIdentifier.Single r) { return false; }
@Override public void sendContacts() {}
@Override public List<Recipient> getRecipients(boolean o, Optional<Boolean> b, Collection<RecipientIdentifier.Single> a, Optional<String> n) { return List.of(); }
@Override public String getContactOrProfileName(RecipientIdentifier.Single r) { return null; }
@Override public Group getGroup(GroupId g) { return null; }
@Override public List<Identity> getIdentities() { return List.of(); }
@Override public List<Identity> getIdentities(RecipientIdentifier.Single r) { return List.of(); }
@Override public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) { return false; }
@Override public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) { return false; }
@Override public void addAddressChangedListener(Runnable l) {}
@Override public void addClosedListener(Runnable l) {}
@Override public InputStream retrieveAttachment(String id) { return null; }
@Override public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) { return null; }
@Override public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) { return null; }
@Override public InputStream retrieveGroupAvatar(GroupId g) { return null; }
@Override public InputStream retrieveSticker(StickerPackId s, int i) { return null; }
@Override public CallInfo startCall(RecipientIdentifier.Single r) { return null; }
@Override public CallInfo acceptCall(long c) { return null; }
@Override public void hangupCall(long c) {}
@Override public void rejectCall(long c) {}
@Override public List<CallInfo> listActiveCalls() { return List.of(); }
@Override public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {}
@Override public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {}
@Override public void sendIceUpdate(RecipientIdentifier.Single r, long c, List<byte[]> i) {}
@Override public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {}
@Override public void sendBusy(RecipientIdentifier.Single r, long c) {}
@Override public List<TurnServer> getTurnServerInfo() { return List.of(); }
@Override public void close() {}
@Override
public Map<String, UserStatus> getUserStatus(Set<String> n) {
return Map.of();
}
@Override
public Map<String, UsernameStatus> getUsernameStatus(Set<String> u) {
return Map.of();
}
@Override
public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {
}
@Override
public Configuration getConfiguration() {
return null;
}
@Override
public void updateConfiguration(Configuration c) {
}
@Override
public void updateProfile(UpdateProfile u) {
}
@Override
public String getUsername() {
return null;
}
@Override
public UsernameLinkUrl getUsernameLink() {
return null;
}
@Override
public void setUsername(String u) {
}
@Override
public void deleteUsername() {
}
@Override
public void startChangeNumber(String n, boolean v, String c) {
}
@Override
public void finishChangeNumber(String n, String v, String p) {
}
@Override
public void unregister() {
}
@Override
public void deleteAccount() {
}
@Override
public void submitRateLimitRecaptchaChallenge(String c, String cap) {
}
@Override
public List<Device> getLinkedDevices() {
return List.of();
}
@Override
public void updateLinkedDevice(int d, String n) {
}
@Override
public void removeLinkedDevices(int d) {
}
@Override
public void addDeviceLink(DeviceLinkUrl u) {
}
@Override
public void setRegistrationLockPin(Optional<String> p) {
}
@Override
public List<Group> getGroups() {
return List.of();
}
@Override
public List<Group> getGroups(Collection<GroupId> g) {
return List.of();
}
@Override
public SendGroupMessageResults quitGroup(GroupId g, Set<RecipientIdentifier.Single> a) {
return null;
}
@Override
public void deleteGroup(GroupId g) {
}
@Override
public Pair<GroupId, SendGroupMessageResults> createGroup(
String n,
Set<RecipientIdentifier.Single> m,
String a
) {
return null;
}
@Override
public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) {
return null;
}
@Override
public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl u) {
return null;
}
@Override
public SendMessageResults sendTypingMessage(TypingAction a, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendMessage(Message m, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public SendMessageResults sendEditMessage(Message m, Set<RecipientIdentifier> r, long t) {
return null;
}
@Override
public SendMessageResults sendRemoteDeleteMessage(long t, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendMessageReaction(
String e,
boolean rm,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendAdminDelete(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier.Group> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPinMessage(
int d,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) {
return null;
}
@Override
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> r) {
return null;
}
@Override
public SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type t,
Set<RecipientIdentifier> r
) {
return null;
}
@Override
public SendMessageResults sendPollCreateMessage(
String q,
boolean a,
List<String> o,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollVoteMessage(
RecipientIdentifier.Single a,
long t,
List<Integer> o,
int v,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollTerminateMessage(long t, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public void hideRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteContact(RecipientIdentifier.Single r) {
}
@Override
public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {
}
@Override
public void setContactsBlocked(Collection<RecipientIdentifier.Single> r, boolean b) {
}
@Override
public void setGroupsBlocked(Collection<GroupId> g, boolean b) {
}
@Override
public void setExpirationTimer(RecipientIdentifier.Single r, int t) {
}
@Override
public StickerPackUrl uploadStickerPack(File p) {
return null;
}
@Override
public void installStickerPack(StickerPackUrl u) {
}
@Override
public List<StickerPack> getStickerPacks() {
return List.of();
}
@Override
public void requestAllSyncData() {
}
@Override
public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {
}
@Override
public void removeReceiveHandler(ReceiveMessageHandler h) {
}
@Override
public boolean isReceiving() {
return false;
}
@Override
public void receiveMessages(Optional<Duration> t, Optional<Integer> m, ReceiveMessageHandler h) {
}
@Override
public void stopReceiveMessages() {
}
@Override
public void setReceiveConfig(ReceiveConfig r) {
}
@Override
public boolean isContactBlocked(RecipientIdentifier.Single r) {
return false;
}
@Override
public void sendContacts() {
}
@Override
public List<Recipient> getRecipients(
boolean o,
Optional<Boolean> b,
Collection<RecipientIdentifier.Single> a,
Optional<String> n
) {
return List.of();
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single r) {
return null;
}
@Override
public Group getGroup(GroupId g) {
return null;
}
@Override
public List<Identity> getIdentities() {
return List.of();
}
@Override
public List<Identity> getIdentities(RecipientIdentifier.Single r) {
return List.of();
}
@Override
public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) {
return false;
}
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) {
return false;
}
@Override
public void addAddressChangedListener(Runnable l) {
}
@Override
public void addClosedListener(Runnable l) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;
}
@Override
public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveGroupAvatar(GroupId g) {
return null;
}
@Override
public InputStream retrieveSticker(StickerPackId s, int i) {
return null;
}
@Override
public CallInfo startCall(RecipientIdentifier.Single r) {
return null;
}
@Override
public CallInfo acceptCall(long c) {
return null;
}
@Override
public void hangupCall(long c) {
}
@Override
public SendMessageResult rejectCall(long c) {
return null;
}
@Override
public List<CallInfo> listActiveCalls() {
return List.of();
}
@Override
public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {
}
@Override
public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {
}
@Override
public void sendIceUpdate(RecipientIdentifier.Single r, long c, List<byte[]> i) {
}
@Override
public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {
}
@Override
public void sendBusy(RecipientIdentifier.Single r, long c) {
}
@Override
public List<TurnServer> getTurnServerInfo() {
return List.of();
}
@Override
public void close() {
}
}
/**
* Minimal MultiAccountManager stub for multi-account mode tests.
*/
private static class StubMultiAccountManager implements MultiAccountManager {
final List<Manager> managers;
final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
@ -182,32 +587,58 @@ class SubscribeCallEventsTest {
this.managers = new ArrayList<>(managers);
}
@Override public List<String> getAccountNumbers() {
@Override
public List<String> getAccountNumbers() {
return managers.stream().map(Manager::getSelfNumber).toList();
}
@Override public List<Manager> getManagers() { return managers; }
@Override
public List<Manager> getManagers() {
return managers;
}
@Override public void addOnManagerAddedHandler(Consumer<Manager> handler) {
@Override
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
addedHandlers.add(handler);
}
@Override public void addOnManagerRemovedHandler(Consumer<Manager> handler) {}
@Override
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
}
@Override public Manager getManager(String phoneNumber) {
@Override
public Manager getManager(String phoneNumber) {
return managers.stream().filter(m -> phoneNumber.equals(m.getSelfNumber())).findFirst().orElse(null);
}
@Override public URI getNewProvisioningDeviceLinkUri() { return null; }
@Override public ProvisioningManager getProvisioningManagerFor(URI u) { return null; }
@Override public RegistrationManager getNewRegistrationManager(String a) { return null; }
@Override public void close() {}
@Override
public URI getNewProvisioningDeviceLinkUri() {
return null;
}
@Override
public ProvisioningManager getProvisioningManagerFor(URI u) {
return null;
}
@Override
public RegistrationManager getNewRegistrationManager(String a) {
return null;
}
@Override
public void close() {
}
}
private static String jsonRpcCall(int id, String method) {
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\"}";
}
private static String jsonRpcCall(int id, String method, String params) {
return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}";
}
// --- Single-account mode tests ---
@Test
@ -243,7 +674,7 @@ class SubscribeCallEventsTest {
}
@Test
void subscribeCallEventsIsIdempotent() {
void subscribeCallEventsCanBeCalledMultipleTimes() {
var manager = new StubManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -254,8 +685,8 @@ class SubscribeCallEventsTest {
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
// Idempotent guard: second call should not add another listener
assertEquals(1, manager.addCount.get(), "duplicate subscribeCallEvents should be ignored");
// The implementation allows multiple subscriptions, so two calls add two listeners
assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners");
}
@Test
@ -265,7 +696,7 @@ class SubscribeCallEventsTest {
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
@ -310,8 +741,8 @@ class SubscribeCallEventsTest {
assertEquals(1, manager1.addCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.addCount.get(), "manager2 should have one listener");
// Also registers an onManagerAdded handler
assertEquals(1, multi.addedHandlers.size(), "should register onManagerAdded handler");
// Also registers an onManagerAdded handler for receive and one for call events
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
}
@Test
@ -324,7 +755,7 @@ class SubscribeCallEventsTest {
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "subscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents"));
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);