mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-22 06:29:04 +00:00
Compare commits
22 Commits
87081807a3
...
c03b96a800
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03b96a800 | ||
|
|
05c1d7bc99 | ||
|
|
d0ee90dbbc | ||
|
|
398faa50b0 | ||
|
|
e9eabbeeb5 | ||
|
|
132dfb95dc | ||
|
|
2651823d4d | ||
|
|
4709cfacc7 | ||
|
|
9bc4c0ecd8 | ||
|
|
763ddf85e6 | ||
|
|
b2bab0d0dc | ||
|
|
62fc96c4c9 | ||
|
|
2667688139 | ||
|
|
990d1eab58 | ||
|
|
e6b33b8da7 | ||
|
|
d40f62ec21 | ||
|
|
265369e353 | ||
|
|
d1106299fe | ||
|
|
7919a0f4aa | ||
|
|
7a8a34f45e | ||
|
|
0a777ea7df | ||
|
|
103a0807ca |
57
.github/workflows/build.yml
vendored
Normal file
57
.github/workflows/build.yml
vendored
Normal 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
|
||||
96
.github/workflows/ci.yml
vendored
96
.github/workflows/ci.yml
vendored
@ -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
|
||||
200
.github/workflows/release.yml
vendored
200
.github/workflows/release.yml
vendored
@ -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
7
.gitignore
vendored
@ -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
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
|
||||
ret.getAciIdentity(),
|
||||
ret.getPniIdentity(),
|
||||
profileKey,
|
||||
ret.getMasterKey(),
|
||||
ret.getAccountEntropyPool(),
|
||||
ret.getMediaRootBackupKey());
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(""),
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
1
libsignal-version
Normal file
@ -0,0 +1 @@
|
||||
0.90.0
|
||||
@ -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:
|
||||
|
||||
34
reproducible-builds/README.md
Normal file
34
reproducible-builds/README.md
Normal 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).
|
||||
13
reproducible-builds/build.Containerfile
Normal file
13
reproducible-builds/build.Containerfile
Normal 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
50
reproducible-builds/build.sh
Executable 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"
|
||||
7
reproducible-builds/client.Containerfile
Normal file
7
reproducible-builds/client.Containerfile
Normal 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" ]
|
||||
78
reproducible-builds/entrypoint.sh
Normal file
78
reproducible-builds/entrypoint.sh
Normal 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
|
||||
7
reproducible-builds/native.Containerfile
Normal file
7
reproducible-builds/native.Containerfile
Normal 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
44
reproducible-builds/verify.sh
Executable 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
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user