Compare commits

..

No commits in common. "master" and "v0.11.10" have entirely different histories.

450 changed files with 14444 additions and 42019 deletions

View File

@ -1,57 +0,0 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
strategy:
matrix:
# java="25" is the LTS Java version used in reproducible builds script (default in Containerfile).
# More Java versions can be added to test compatibility, eg. "26".
java: ["25", "26"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
if [ "${{ matrix.java }}" != "25" ]; 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-archive-${{ matrix.java }}
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

58
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '17', '20' ]
steps:
- uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: ${{ matrix.java }}
cache: 'gradle'
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: graalvm/setup-graalvm@v1
with:
version: 'latest'
java-version: '17'
cache: 'gradle'
components: 'native-image'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v3
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli

View File

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

View File

@ -1,27 +0,0 @@
name: dependency-graph
on:
push:
branches:
- "**"
permissions: {}
jobs:
submit-dependency-graph:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Generate dependency graph
run: ./gradlew --no-daemon dependencies

View File

@ -5,78 +5,185 @@ on:
tags: tags:
- v* - v*
permissions: {} permissions:
contents: read # to fetch code (actions/checkout)
env: env:
IMAGE_NAME: signal-cli IMAGE_NAME: signal-cli
IMAGE_REGISTRY: ghcr.io/asamk IMAGE_REGISTRY: ghcr.io/asamk
REGISTRY_USER: ${{ github.actor }} REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }} REGISTRY_PASSWORD: ${{ github.token }}
ARCHIVE_JAVA_VERSION: 25
jobs: jobs:
build:
uses: ./.github/workflows/build.yml
release: ci_wf:
needs: build uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
outputs: outputs:
version: ${{ steps.version.outputs.version }} signal_cli_version: ${{ steps.cli_ver.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
steps: steps:
- name: Download signal-cli build from CI workflow - name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8 uses: actions/download-artifact@v3
- name: Get signal-cli version - name: Get signal-cli version
id: version id: cli_ver
run: | run: |
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* . ver="${GITHUB_REF_NAME#v}"
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Create draft release and upload assets - name: Extract archive
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release create "v${{ steps.version.outputs.version }}" \ tree .
--repo "${{ github.repository }}" \ ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
--title "v${{ steps.version.outputs.version }}" \ tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
--draft \ mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
--verify-tag \ rm -rf signal-cli-archive-*/
"signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \ - name: Get signal-client jar version
"signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \ id: lib_ver
"signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz" 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: 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-*/
- name: Create release
id: create_release
uses: actions/create-release@v1
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`
draft: true
- 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
- name: Upload Linux native 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-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
- 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
build-container: build-container:
needs: release needs: ci_wf
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v3
- name: Download signal-cli build from CI workflow - name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8 uses: actions/download-artifact@v3
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file - name: Move archive file
run: | run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}.tar.gz ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
mkdir -p build/install/ mkdir -p build/install/
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli mv ./signal-cli-*/ build/install/signal-cli
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v3 uses: redhat-actions/buildah-build@v2
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }} tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }}
containerfiles: ./Containerfile containerfiles:
./Containerfile
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v3 uses: redhat-actions/push-to-registry@v2
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}
@ -90,75 +197,41 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}" echo "${{ toJSON(steps.push.outputs) }}"
build-container-native: build-container-native:
needs: release needs: ci_wf
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v3
- name: Download signal-cli build from CI workflow - name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8 uses: actions/download-artifact@v3
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file - name: Move archive file
run: | run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/ mkdir -p build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/ chmod +x ./signal-cli-native/signal-cli
chmod +x build/native/nativeCompile/signal-cli mv ./signal-cli-native/signal-cli build/native/nativeCompile/
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v3 uses: redhat-actions/buildah-build@v2
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native
containerfiles: ./native.Containerfile containerfiles:
./native.Containerfile
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v3 uses: redhat-actions/push-to-registry@v2
id: push
with:
image: ${{ steps.build_image.outputs.image }}
tags: ${{ steps.build_image.outputs.tags }}
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Echo outputs
run: |
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: release
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Move archive file
run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
mkdir -p 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@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}

8
.gitignore vendored
View File

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

View File

@ -5,8 +5,8 @@
<option name="GENERATE_FINAL_LOCALS" value="true" /> <option name="GENERATE_FINAL_LOCALS" value="true" />
<option name="GENERATE_FINAL_PARAMETERS" value="true" /> <option name="GENERATE_FINAL_PARAMETERS" value="true" />
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" /> <option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" /> <option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50" />
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" /> <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50" />
<option name="IMPORT_LAYOUT_TABLE"> <option name="IMPORT_LAYOUT_TABLE">
<value> <value>
<package name="com" withSubpackages="true" static="false" /> <package name="com" withSubpackages="true" static="false" />

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ If you have a question you can ask it in the [GitHub discussions page](https://g
- Be sure to include a **title and clear description**, as much relevant information as possible. - Be sure to include a **title and clear description**, as much relevant information as possible.
- Specify the versions of signal-cli, libsignal-client (if self-compiled), JDK and OS you're using - Specify the versions of signal-cli, libsignal-client (if self-compiled), JDK and OS you're using
- Specify if it's the normal java or the graalvm native version. - Specify if it's the normal java or the graalvm native version.
- Run the failing command with `-vv --scrub-log` flags to get a more detailed log output and include that in the bug report - Run the failing command with `--verbose` flag to get a more detailed log output and include that in the bug report
# Pull request # Pull request

View File

@ -1,4 +1,4 @@
FROM docker.io/azul/zulu-openjdk:25-jre-headless FROM docker.io/eclipse-temurin:17-jre
LABEL org.opencontainers.image.source=https://github.com/AsamK/signal-cli LABEL org.opencontainers.image.source=https://github.com/AsamK/signal-cli
LABEL org.opencontainers.image.description="signal-cli provides an unofficial commandline, dbus and JSON-RPC interface for the Signal messenger." LABEL org.opencontainers.image.description="signal-cli provides an unofficial commandline, dbus and JSON-RPC interface for the Signal messenger."

View File

@ -3,18 +3,13 @@
signal-cli is a commandline interface for the [Signal messenger](https://signal.org/). signal-cli is a commandline interface for the [Signal messenger](https://signal.org/).
It supports registering, verifying, sending and receiving messages. It supports registering, verifying, sending and receiving messages.
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java), signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service). extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/libsignal/service).
For registering you need a phone number where you can receive SMS or incoming calls. For registering you need a phone number where you can receive SMS or incoming calls.
signal-cli is primarily intended to be used on servers to notify admins of important events. signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a daemon mode with D-BUS
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc)) interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)) and JSON-RPC interface ([documentation](https://github.com/AsamK/signal-cli/wiki/JSON-RPC-service)).
and D-BUS interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)).
For the JSON-RPC interface there's also a simple [example client](https://github.com/AsamK/signal-cli/tree/master/client), written in Rust. For the JSON-RPC interface there's also a simple [example client](https://github.com/AsamK/signal-cli/tree/master/client), written in Rust.
signal-cli needs to be kept up-to-date to keep up with Signal-Server changes.
The official Signal clients expire after three months and then the Signal-Server can make incompatible changes.
So signal-cli releases older than three months may not work correctly.
## Installation ## Installation
You can [build signal-cli](#building) yourself or use You can [build signal-cli](#building) yourself or use
@ -23,36 +18,28 @@ Windows. There's also a [docker image and some Linux packages](https://github.co
System requirements: System requirements:
- at least Java Runtime Environment (JRE) 25 - at least Java Runtime Environment (JRE) 17
- native library: libsignal-client - native library: libsignal-client
The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other
systems/architectures systems/architectures
see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal) see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal)
### Install system-wide on Linux [ JVM build ] ### Install system-wide on Linux
See [latest version](https://github.com/AsamK/signal-cli/releases). See [latest version](https://github.com/AsamK/signal-cli/releases).
```sh ```sh
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//') export VERSION=<latest version, format "x.y.z">
curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}".tar.gz wget https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}"-Linux.tar.gz
sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt sudo tar xf signal-cli-"${VERSION}"-Linux.tar.gz -C /opt
sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/ sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/
``` ```
### Install system-wide on Linux [ GraalVM native build ]
```sh
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}"-Linux-native.tar.gz
sudo tar xf signal-cli-"${VERSION}"-Linux-native.tar.gz -C /opt
sudo ln -sf /opt/signal-cli /usr/local/bin/
```
You can find further instructions on the Wiki: You can find further instructions on the Wiki:
- [Quickstart](https://github.com/AsamK/signal-cli/wiki/Quickstart) - [Quickstart](https://github.com/AsamK/signal-cli/wiki/Quickstart)
- [DBus Service](https://github.com/AsamK/signal-cli/wiki/DBus-service)
## Usage ## Usage
@ -64,31 +51,12 @@ Important: The ACCOUNT is your phone number in international format and must inc
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
of all country codes.) of all country codes.)
* Link to an existing account
If you have an existing Signal account associated
with a number, you can link signal-cli to it with:
signal-cli link
* Register a number (with SMS verification) * Register a number (with SMS verification)
Alternatively, if you don't have an existing Signal
account, you can register one from signal-cli. Note
that this will unregister any existing client
associated with the same number.
signal-cli -a ACCOUNT register signal-cli -a ACCOUNT register
You can register Signal using a landline number. In this case, you need to follow the procedure below: You can register Signal using a landline number. In this case you can skip SMS verification process and jump directly
* Attempt a SMS verification process first (`signal-cli -a ACCOUNT register`) to the voice call verification by adding the `--voice` switch at the end of above register command.
* You will get an error `400 (InvalidTransportModeException)`, this is normal
* Wait 60 seconds
* Attempt a voice call verification by adding the `--voice` switch and wait for the call:
```sh
signal-cli -a ACCOUNT register --voice
```
Registering may require solving a CAPTCHA Registering may require solving a CAPTCHA
challenge: [Registration with captcha](https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha) challenge: [Registration with captcha](https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha)
@ -100,15 +68,7 @@ of all country codes.)
* Send a message * Send a message
```sh
signal-cli -a ACCOUNT send -m "This is a message" RECIPIENT signal-cli -a ACCOUNT send -m "This is a message" RECIPIENT
```
* Send a message to a username, usernames need to be prefixed with `u:`
```sh
signal-cli -a ACCOUNT send -m "This is a message" u:USERNAME.000
```
* Pipe the message content from another process. * Pipe the message content from another process.
@ -156,27 +116,16 @@ version installed, you can replace `./gradlew` with `gradle` in the following st
2d. Compile and run signal-cli: 2d. Compile and run signal-cli:
```sh
./gradlew run --args="--help" ./gradlew run --args="--help"
```
### JSON Schemas for the JSON-RPC mode
1. Generate [JSON Schema](https://json-schema.org/) files for all the JSON-RPC data classes (`src/main/java/org/asamk/signal/json`):
```sh
./gradlew jsonSchemas
```
2. The generated files can be found in the `build/generated/META-INF/schemas` folder.
### Building a native binary with GraalVM (EXPERIMENTAL) ### Building a native binary with GraalVM (EXPERIMENTAL)
It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not
work in all situations. work in all situations.
1. [Install GraalVM and setup the environment](https://www.graalvm.org/docs/getting-started/#install-graalvm) 1. [Install GraalVM and setup the enviroment](https://www.graalvm.org/docs/getting-started/#install-graalvm)
2. Execute Gradle: 2. [Install prerequisites](https://www.graalvm.org/reference-manual/native-image/#prerequisites)
3. Execute Gradle:
./gradlew nativeCompile ./gradlew nativeCompile
@ -184,7 +133,7 @@ work in all situations.
## FAQ and Troubleshooting ## FAQ and Troubleshooting
For frequently asked questions and issues have a look at the [wiki](https://github.com/AsamK/signal-cli/wiki/FAQ). For frequently asked questions and issues have a look at the [wiki](https://github.com/AsamK/signal-cli/wiki/FAQ)
## License ## License

View File

@ -1,46 +1,31 @@
import groovy.json.JsonOutput
plugins { plugins {
java java
application application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "1.1.9" id("org.graalvm.buildtools.native") version "0.9.21"
} }
allprojects { version = "0.11.10"
group = "org.asamk"
version = "0.14.8"
}
java { java {
sourceCompatibility = JavaVersion.VERSION_25 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_17
if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) {
toolchain {
languageVersion.set(JavaLanguageVersion.of(targetCompatibility.majorVersion))
}
}
} }
application { application {
mainClass.set("org.asamk.signal.Main") mainClass.set("org.asamk.signal.Main")
applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
} }
graalvmNative { graalvmNative {
binaries { binaries {
this["main"].run { this["main"].run {
buildArgs.add("-Dfile.encoding=UTF-8")
buildArgs.add("-J-Dfile.encoding=UTF-8")
buildArgs.add("-march=compatibility")
buildArgs.add("--enable-native-access=ALL-UNNAMED")
resources.autodetect() resources.autodetect()
configurationFileDirectories.from(file("graalvm-config-dir"))
if (System.getenv("GRAALVM_HOME") == null) { if (System.getenv("GRAALVM_HOME") == null) {
toolchainDetection.set(true) toolchainDetection.set(true)
javaLauncher.set(javaToolchains.launcherFor { javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(25)) languageVersion.set(JavaLanguageVersion.of(17))
}) })
} else { } else {
toolchainDetection.set(false) toolchainDetection.set(false)
@ -49,48 +34,7 @@ graalvmNative {
} }
} }
val artifactType = Attribute.of("artifactType", String::class.java)
val minified = Attribute.of("minified", Boolean::class.javaObjectType)
dependencies { dependencies {
attributesSchema {
attribute(minified)
}
artifactTypes.getByName("jar") {
attributes.attribute(minified, false)
}
}
configurations.runtimeClasspath.configure {
attributes {
attribute(minified, true)
}
}
val excludePatterns = mapOf(
"libsignal-client" to setOf(
"libsignal_jni_testing_amd64.so",
"signal_jni_testing_amd64.dll",
"libsignal_jni_testing_amd64.dylib",
"libsignal_jni_testing_aarch64.dylib",
)
)
val schemaAnnotationProcessor = configurations.create("schemaAnnotationProcessor") {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies {
registerTransform(JarFileExcluder::class) {
from.attribute(minified, false).attribute(artifactType, "jar")
to.attribute(minified, true).attribute(artifactType, "jar")
parameters {
excludeFilesByArtifact = excludePatterns
}
}
schemaAnnotationProcessor(libs.micronaut.json.schema.processor)
schemaAnnotationProcessor(libs.micronaut.inject.java)
implementation(libs.bouncycastle) implementation(libs.bouncycastle)
implementation(libs.jackson.databind) implementation(libs.jackson.databind)
implementation(libs.argparse4j) implementation(libs.argparse4j)
@ -98,20 +42,7 @@ dependencies {
implementation(libs.slf4j.api) implementation(libs.slf4j.api)
implementation(libs.slf4j.jul) implementation(libs.slf4j.jul)
implementation(libs.logback) implementation(libs.logback)
implementation(libs.zxing) implementation(project(":lib"))
implementation(libs.micronaut.json.schema.annotations)
if (gradle.startParameter.taskNames.any { it.contains("jsonSchemas") }) {
implementation(libs.micronaut.json.schema.generator)
}
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
} }
configurations { configurations {
@ -135,68 +66,23 @@ tasks.withType<Jar> {
attributes( attributes(
"Implementation-Title" to project.name, "Implementation-Title" to project.name,
"Implementation-Version" to project.version, "Implementation-Version" to project.version,
"Main-Class" to application.mainClass.get(), "Main-Class" to application.mainClass.get()
"Enable-Native-Access" to "ALL-UNNAMED",
) )
} }
} }
tasks.register("fatJar", type = Jar::class) { task("fatJar", type = Jar::class) {
archiveBaseName.set("${project.name}-fat") archiveBaseName.set("${project.name}-fat")
exclude( exclude(
"META-INF/*.SF", "META-INF/*.SF",
"META-INF/**/*.MF",
"META-INF/*.DSA", "META-INF/*.DSA",
"META-INF/*.RSA", "META-INF/*.RSA",
"META-INF/NOTICE*", "META-INF/NOTICE*",
"META-INF/LICENSE*", "META-INF/LICENSE*",
"META-INF/INDEX.LIST", "META-INF/INDEX.LIST",
"**/module-info.class", "**/module-info.class"
) )
duplicatesStrategy = DuplicatesStrategy.WARN duplicatesStrategy = DuplicatesStrategy.WARN
doFirst {
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
}
with(tasks.jar.get()) 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")
}
}
}
tasks.register<JavaCompile>("jsonSchemas") {
dependsOn(tasks.compileJava)
val schemaBaseUri = "http://localhost:8080/schemas/"
source = sourceSets.main.get().java
include("org/asamk/signal/json/**/*.java")
classpath = sourceSets.main.get().compileClasspath + files(sourceSets.main.get().java.destinationDirectory)
destinationDirectory.set(layout.buildDirectory.dir("generated"))
options.annotationProcessorPath = schemaAnnotationProcessor
options.compilerArgs.addAll(
listOf(
"-Amicronaut.processing.group=org.asamk",
"-Amicronaut.processing.module=signal-cli",
"-Amicronaut.processing.annotations=org.asamk.signal.json.*",
"-Amicronaut.jsonschema.baseUri=$schemaBaseUri",
)
)
doLast {
fileTree(destinationDirectory.get().dir("META-INF/schemas").asFile) {
include("*.schema.json")
}.forEach { schemaFile ->
val normalized = schemaFile.readText().replace("\"$schemaBaseUri/", "\"")
val prettyJson = JsonOutput.prettyPrint(normalized)
schemaFile.writeText("$prettyJson\n")
}
}
}

View File

@ -7,11 +7,11 @@ plugins {
} }
tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure { tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_25) compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
} }
java { java {
targetCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_17
} }
repositories { repositories {

View File

@ -1,12 +1,14 @@
import groovy.util.XmlSlurper
import groovy.util.slurpersupport.GPathResult
import org.codehaus.groovy.runtime.ResourceGroovyMethods
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.Task import org.gradle.api.Task
import org.gradle.api.artifacts.Dependency import org.gradle.api.artifacts.Dependency
import javax.xml.parsers.DocumentBuilderFactory
class CheckLibVersionsPlugin : Plugin<Project> { class CheckLibVersionsPlugin : Plugin<Project> {
override fun apply(project: Project) { override fun apply(project: Project) {
project.tasks.register("checkLibVersions") { project.task("checkLibVersions") {
description = description =
"Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded." "Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded."
doLast { doLast {
@ -24,15 +26,15 @@ class CheckLibVersionsPlugin : Plugin<Project> {
val name = dependency.name val name = dependency.name
val metaDataUrl = "https://repo1.maven.org/maven2/$path/$name/maven-metadata.xml" val metaDataUrl = "https://repo1.maven.org/maven2/$path/$name/maven-metadata.xml"
try { try {
val dbf = DocumentBuilderFactory.newInstance() val url = ResourceGroovyMethods.toURL(metaDataUrl)
val db = dbf.newDocumentBuilder() val metaDataText = ResourceGroovyMethods.getText(url)
val doc = db.parse(metaDataUrl) val metadata = XmlSlurper().parseText(metaDataText)
val newest = doc.getElementsByTagName("latest").item(0).textContent val newest = (metadata.getProperty("versioning") as GPathResult).getProperty("latest")
if (version != newest.toString()) { if (version != newest.toString()) {
println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}") println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}")
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logger.debug("Unable to download or parse {}: {}", metaDataUrl, e.message) logger.debug("Unable to download or parse $metaDataUrl: $e.message")
} }
} }
} }

View File

@ -1,53 +0,0 @@
import org.gradle.api.artifacts.transform.*
import org.gradle.api.file.FileSystemLocation
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@CacheableTransform
abstract class JarFileExcluder : TransformAction<JarFileExcluder.Parameters> {
interface Parameters : TransformParameters {
@get:Input
var excludeFilesByArtifact: Map<String, Set<String>>
}
@get:PathSensitive(PathSensitivity.NAME_ONLY)
@get:InputArtifact
abstract val inputArtifact: Provider<FileSystemLocation>
override
fun transform(outputs: TransformOutputs) {
val fileName = inputArtifact.get().asFile.name
for (entry in parameters.excludeFilesByArtifact) {
if (fileName.startsWith(entry.key)) {
val nameWithoutExtension = fileName.substring(0, fileName.lastIndexOf("."))
excludeFiles(inputArtifact.get().asFile, entry.value, outputs.file("${nameWithoutExtension}.jar"))
return
}
}
outputs.file(inputArtifact)
}
private fun excludeFiles(artifact: File, excludeFiles: Set<String>, jarFile: File) {
ZipInputStream(FileInputStream(artifact)).use { input ->
ZipOutputStream(FileOutputStream(jarFile)).use { output ->
var entry = input.nextEntry
while (entry != null) {
if (!excludeFiles.contains(entry.name)) {
output.putNextEntry(entry)
input.copyTo(output)
output.closeEntry()
}
entry = input.nextEntry
}
}
}
}
}

View File

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

1747
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,25 @@
[package] [package]
name = "signal-cli-client" name = "signal-cli-client"
version = "0.0.1" version = "0.0.1"
edition = "2024" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
clap = { version = "4", features = ["cargo", "derive", "wrap_help"] } clap = { version = "4", features = ["cargo", "derive", "wrap_help"] }
jsonrpc-core = "18"
jsonrpc-core-client = "18"
jsonrpc-client-transports = { version = "18", default-features = false, features = [
"ipc",
] }
jsonrpc-derive = "18"
jsonrpc-server-utils = "18"
log = "0.4"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["rt", "macros", "net", "rt-multi-thread"] } tokio = { version = "1", features = ["rt", "macros", "net"] }
jsonrpsee = { version = "0.26", features = [
"macros", [patch.crates-io]
"async-client", jsonrpc-client-transports = { git = "https://github.com/AsamK/jsonrpc", branch = "client_subscribe_named_params" }
"http-client", jsonrpc-derive = { git = "https://github.com/AsamK/jsonrpc", branch = "client_subscribe_named_params" }
] }
bytes = "1"
tokio-util = "0.7"
futures-util = "0.3"
thiserror = "2"

View File

@ -1,30 +1,24 @@
use std::{ffi::OsString, net::SocketAddr};
use clap::{crate_version, Parser, Subcommand, ValueEnum}; use clap::{crate_version, Parser, Subcommand, ValueEnum};
use std::{ffi::OsString, net::SocketAddr};
/// JSON-RPC client for signal-cli /// JSON-RPC client for signal-cli
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(rename_all = "kebab-case", version = crate_version!())] #[command(rename_all = "kebab-case", version=crate_version!())]
pub struct Cli { pub struct Cli {
/// Account to use (for daemon in multi-account mode) /// Account to use (for daemon in multi-account mode)
#[arg(short = 'a', long)] #[arg(short = 'a', long)]
pub account: Option<String>, pub account: Option<String>,
#[arg(long)]
pub output: Option<String>,
/// TCP host and port of signal-cli daemon /// TCP host and port of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_http")] #[arg(long)]
pub json_rpc_tcp: Option<Option<SocketAddr>>, pub json_rpc_tcp: Option<Option<SocketAddr>>,
/// UNIX socket address and port of signal-cli daemon /// UNIX socket address and port of signal-cli daemon
#[cfg(unix)] #[arg(long)]
#[arg(long, conflicts_with = "json_rpc_tcp")]
pub json_rpc_socket: Option<Option<OsString>>, pub json_rpc_socket: Option<Option<OsString>>,
/// HTTP URL of signal-cli daemon #[arg(value_enum, long, default_value_t = OutputTypes::Json)]
#[arg(long, conflicts_with = "json_rpc_socket")] pub output: OutputTypes,
pub json_rpc_http: Option<Option<String>>,
#[arg(long)] #[arg(long)]
pub verbose: bool, pub verbose: bool,
@ -33,18 +27,21 @@ pub struct Cli {
pub command: CliCommands, pub command: CliCommands,
} }
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum OutputTypes {
PlainText,
Json,
}
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
#[command(rename_all = "camelCase", version = crate_version!())] #[command(rename_all = "camelCase", version=crate_version!())]
pub enum CliCommands { pub enum CliCommands {
AddDevice { AddDevice {
#[arg(long)] #[arg(long)]
uri: String, uri: String,
}, },
AddStickerPack {
#[arg(long)]
uri: String,
},
#[command(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")]
Block { Block {
recipient: Vec<String>, recipient: Vec<String>,
@ -56,40 +53,8 @@ pub enum CliCommands {
#[arg(long = "ignore-registered")] #[arg(long = "ignore-registered")]
ignore_registered: Option<bool>, ignore_registered: Option<bool>,
}, },
FinishChangeNumber {
number: String,
#[arg(short = 'v', long = "verification-code")]
verification_code: String,
#[arg(short = 'p', long)]
pin: Option<String>,
},
GetAttachment {
#[arg(long)]
id: String,
#[arg(long)]
recipient: Option<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Option<String>,
},
GetAvatar {
#[arg(long)]
contact: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Option<String>,
},
GetSticker {
#[arg(long = "pack-id")]
pack_id: String,
#[arg(long = "sticker-id")]
sticker_id: u32,
},
GetUserStatus { GetUserStatus {
recipient: Vec<String>, recipient: Vec<String>,
#[arg(long)]
username: Vec<String>,
}, },
JoinGroup { JoinGroup {
#[arg(long)] #[arg(long)]
@ -97,7 +62,7 @@ pub enum CliCommands {
}, },
Link { Link {
#[arg(short = 'n', long)] #[arg(short = 'n', long)]
name: Option<String>, name: String,
}, },
ListAccounts, ListAccounts,
ListContacts { ListContacts {
@ -108,10 +73,6 @@ pub enum CliCommands {
blocked: Option<bool>, blocked: Option<bool>,
#[arg(long)] #[arg(long)]
name: Option<String>, name: Option<String>,
#[arg(long)]
detailed: bool,
#[arg(long)]
internal: bool,
}, },
ListDevices, ListDevices,
ListGroups { ListGroups {
@ -142,15 +103,11 @@ pub enum CliCommands {
voice: bool, voice: bool,
#[arg(long)] #[arg(long)]
captcha: Option<String>, captcha: Option<String>,
#[arg(long)]
reregister: bool,
}, },
RemoveContact { RemoveContact {
recipient: String, recipient: String,
#[arg(long)] #[arg(long)]
forget: bool, forget: bool,
#[arg(long)]
hide: bool,
}, },
RemoveDevice { RemoveDevice {
#[arg(short = 'd', long = "device-id")] #[arg(short = 'd', long = "device-id")]
@ -176,36 +133,21 @@ pub enum CliCommands {
#[arg(short = 'g', long)] #[arg(short = 'g', long)]
group_id: Vec<String>, group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long)] #[arg(long)]
note_to_self: bool, note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
end_session: bool, end_session: bool,
#[arg(short = 'm', long)] #[arg(short = 'm', long)]
message: Option<String>, message: Option<String>,
#[arg(long)]
message_from_stdin: bool,
#[arg(short = 'a', long)] #[arg(short = 'a', long)]
attachment: Vec<String>, attachment: Vec<String>,
#[arg(long)]
view_once: bool,
#[arg(long)] #[arg(long)]
mention: Vec<String>, mention: Vec<String>,
#[arg(long)]
text_style: Vec<String>,
#[arg(long)] #[arg(long)]
quote_timestamp: Option<u64>, quote_timestamp: Option<u64>,
@ -218,24 +160,6 @@ pub enum CliCommands {
#[arg(long)] #[arg(long)]
quote_mention: Vec<String>, quote_mention: Vec<String>,
#[arg(long)]
quote_text_style: Vec<String>,
#[arg(long)]
quote_attachment: Vec<String>,
#[arg(long)]
preview_url: Option<String>,
#[arg(long)]
preview_title: Option<String>,
#[arg(long)]
preview_description: Option<String>,
#[arg(long)]
preview_image: Option<String>,
#[arg(long)] #[arg(long)]
sticker: Option<String>, sticker: Option<String>,
@ -244,28 +168,6 @@ pub enum CliCommands {
#[arg(long)] #[arg(long)]
story_author: Option<String>, story_author: Option<String>,
#[arg(long)]
edit_timestamp: Option<u64>,
#[arg(long = "no-urgent")]
no_urgent: bool,
},
SendAdminDelete {
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long)]
story: bool,
#[arg(long)]
notify_self: bool,
}, },
SendContacts, SendContacts,
SendPaymentNotification { SendPaymentNotification {
@ -277,117 +179,15 @@ pub enum CliCommands {
#[arg(long)] #[arg(long)]
note: String, note: String,
}, },
SendPinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(short = 'd', long = "pin-duration")]
pin_duration: Option<i32>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendPollCreate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'q', long = "question")]
question: String,
#[arg(short = 'o', long = "option")]
option: Vec<String>,
#[arg(long = "no-multi")]
no_multi: bool,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollTerminate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollVote {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-author")]
poll_author: Option<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(short = 'o', long = "option")]
option: Vec<i32>,
#[arg(long = "vote-count")]
vote_count: i32,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendReaction { SendReaction {
recipient: Vec<String>, recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")] #[arg(short = 'g', long = "group-id")]
group_id: Vec<String>, group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "note-to-self")] #[arg(long = "note-to-self")]
note_to_self: bool, note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
emoji: String, emoji: String,
@ -406,9 +206,6 @@ pub enum CliCommands {
SendReceipt { SendReceipt {
recipient: String, recipient: String,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 't', long = "target-timestamp")] #[arg(short = 't', long = "target-timestamp")]
target_timestamp: Vec<u64>, target_timestamp: Vec<u64>,
@ -425,49 +222,9 @@ pub enum CliCommands {
#[arg(short = 's', long)] #[arg(short = 's', long)]
stop: bool, stop: bool,
}, },
SendUnpinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendMessageRequestResponse {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(long)]
r#type: MessageRequestResponseType,
},
SetPin { SetPin {
pin: String, pin: String,
}, },
StartChangeNumber {
number: String,
#[arg(short = 'v', long)]
voice: bool,
#[arg(long)]
captcha: Option<String>,
},
SubmitRateLimitChallenge { SubmitRateLimitChallenge {
challenge: String, challenge: String,
captcha: String, captcha: String,
@ -495,16 +252,6 @@ pub enum CliCommands {
UpdateAccount { UpdateAccount {
#[arg(short = 'n', long = "device-name")] #[arg(short = 'n', long = "device-name")]
device_name: Option<String>, device_name: Option<String>,
#[arg(long = "unrestricted-unidentified-sender")]
unrestricted_unidentified_sender: Option<bool>,
#[arg(long = "discoverable-by-number")]
discoverable_by_number: Option<bool>,
#[arg(long = "number-sharing")]
number_sharing: Option<bool>,
#[arg(short = 'u', long = "username")]
username: Option<String>,
#[arg(long = "delete-username")]
delete_username: bool,
}, },
UpdateConfiguration { UpdateConfiguration {
#[arg(long = "read-receipts")] #[arg(long = "read-receipts")]
@ -527,28 +274,6 @@ pub enum CliCommands {
#[arg(short = 'n', long)] #[arg(short = 'n', long)]
name: Option<String>, name: Option<String>,
#[arg(long = "given-name")]
given_name: Option<String>,
#[arg(long = "family-name")]
family_name: Option<String>,
#[arg(long = "nick-given-name")]
nick_given_name: Option<String>,
#[arg(long = "nick-family-name")]
nick_family_name: Option<String>,
#[arg(long)]
note: Option<String>,
},
UpdateDevice {
#[arg(short = 'd', long = "device-id")]
device_id: u32,
#[arg(short = 'n', long = "device-name")]
device_name: String,
}, },
UpdateGroup { UpdateGroup {
#[arg(short = 'g', long = "group-id")] #[arg(short = 'g', long = "group-id")]
@ -598,12 +323,6 @@ pub enum CliCommands {
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
expiration: Option<u32>, expiration: Option<u32>,
#[arg(long = "member-label-emoji")]
member_label_emoji: Option<String>,
#[arg(long = "member-label")]
member_label: Option<String>,
}, },
UpdateProfile { UpdateProfile {
#[arg(long = "given-name")] #[arg(long = "given-name")]
@ -618,7 +337,7 @@ pub enum CliCommands {
#[arg(long = "about-emoji")] #[arg(long = "about-emoji")]
about_emoji: Option<String>, about_emoji: Option<String>,
#[arg(long = "mobile-coin-address", visible_alias = "mobilecoin-address")] #[arg(long = "mobile-coin-address")]
mobile_coin_address: Option<String>, mobile_coin_address: Option<String>,
#[arg(long)] #[arg(long)]
@ -660,10 +379,3 @@ pub enum GroupPermission {
EveryMember, EveryMember,
OnlyAdmins, OnlyAdmins,
} }
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum MessageRequestResponseType {
Accept,
Delete,
}

View File

@ -1,102 +1,49 @@
use std::path::Path; use std::path::Path;
use jsonrpsee::async_client::ClientBuilder; use jsonrpc_client_transports::{transports::ipc, RpcError};
use jsonrpsee::core::client::{Error, SubscriptionClientT}; use jsonrpc_core::serde::Deserialize;
use jsonrpsee::http_client::HttpClientBuilder; use jsonrpc_derive::rpc;
use jsonrpsee::proc_macros::rpc;
use serde::Deserialize;
use serde_json::Value;
use tokio::net::ToSocketAddrs; use tokio::net::ToSocketAddrs;
#[rpc(client)] pub type SignalCliClient = gen_client::Client;
#[rpc(client, params = "named")]
pub trait Rpc { pub trait Rpc {
#[method(name = "addDevice", param_kind = map)] #[rpc(name = "addDevice", params = "named")]
async fn add_device( fn add_device(&self, account: Option<String>, uri: String) -> Result<Value>;
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "addStickerPack", param_kind = map)] #[rpc(name = "block", params = "named")]
async fn add_sticker_pack(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "block", param_kind = map)]
fn block( fn block(
&self, &self,
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "deleteLocalAccountData", param_kind = map)] #[rpc(name = "deleteLocalAccountData", params = "named")]
fn delete_local_account_data( fn delete_local_account_data(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] ignoreRegistered: Option<bool>, #[allow(non_snake_case)] ignoreRegistered: Option<bool>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "getAttachment", param_kind = map)] #[rpc(name = "getUserStatus", params = "named")]
fn get_attachment( fn get_user_status(&self, account: Option<String>, recipients: Vec<String>) -> Result<Value>;
&self,
account: Option<String>,
id: String,
recipient: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAvatar", param_kind = map)] #[rpc(name = "joinGroup", params = "named")]
fn get_avatar( fn join_group(&self, account: Option<String>, uri: String) -> Result<Value>;
&self,
account: Option<String>,
contact: Option<String>,
profile: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getSticker", param_kind = map)] #[rpc(name = "finishLink", params = "named")]
fn get_sticker(
&self,
account: Option<String>,
#[allow(non_snake_case)] packId: String,
#[allow(non_snake_case)] stickerId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getUserStatus", param_kind = map)]
fn get_user_status(
&self,
account: Option<String>,
recipients: Vec<String>,
usernames: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "joinGroup", param_kind = map)]
fn join_group(&self, account: Option<String>, uri: String) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "finishChangeNumber", param_kind = map)]
fn finish_change_number(
&self,
account: Option<String>,
number: String,
verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "finishLink", param_kind = map)]
fn finish_link( fn finish_link(
&self, &self,
#[allow(non_snake_case)] deviceLinkUri: String, #[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: Option<String>, #[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "listAccounts", param_kind = map)] #[rpc(name = "listAccounts", params = "named")]
fn list_accounts(&self) -> Result<Value, ErrorObjectOwned>; fn list_accounts(&self) -> Result<Value>;
#[method(name = "listContacts", param_kind = map)] #[rpc(name = "listContacts", params = "named")]
fn list_contacts( fn list_contacts(
&self, &self,
account: Option<String>, account: Option<String>,
@ -104,68 +51,60 @@ pub trait Rpc {
#[allow(non_snake_case)] allRecipients: bool, #[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>, blocked: Option<bool>,
name: Option<String>, name: Option<String>,
detailed: bool, ) -> Result<Value>;
internal: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)] #[rpc(name = "listDevices", params = "named")]
fn list_devices(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; fn list_devices(&self, account: Option<String>) -> Result<Value>;
#[method(name = "listGroups", param_kind = map)] #[rpc(name = "listGroups", params = "named")]
fn list_groups( fn list_groups(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "listIdentities", param_kind = map)] #[rpc(name = "listIdentities", params = "named")]
fn list_identities( fn list_identities(&self, account: Option<String>, number: Option<String>) -> Result<Value>;
&self,
account: Option<String>,
number: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listStickerPacks", param_kind = map)] #[rpc(name = "listStickerPacks", params = "named")]
fn list_sticker_packs(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; fn list_sticker_packs(&self, account: Option<String>) -> Result<Value>;
#[method(name = "quitGroup", param_kind = map)] #[rpc(name = "quitGroup", params = "named")]
fn quit_group( fn quit_group(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] groupId: String, #[allow(non_snake_case)] groupId: String,
delete: bool, delete: bool,
admins: Vec<String>, admins: Vec<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "register", param_kind = map)] #[rpc(name = "register", params = "named")]
fn register( fn register(
&self, &self,
account: Option<String>, account: Option<String>,
voice: bool, voice: bool,
captcha: Option<String>, captcha: Option<String>,
reregister: bool, ) -> Result<Value>;
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)] #[rpc(name = "removeContact", params = "named")]
fn remove_contact( fn remove_contact(
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
forget: bool, forget: bool,
hide: bool, ) -> Result<Value>;
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeDevice", param_kind = map)] #[rpc(name = "removeDevice", params = "named")]
fn remove_device( fn remove_device(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] deviceId: u32, #[allow(non_snake_case)] deviceId: u32,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "removePin", param_kind = map)] #[rpc(name = "removePin", params = "named")]
fn remove_pin(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; fn remove_pin(&self, account: Option<String>) -> Result<Value>;
#[method(name = "remoteDelete", param_kind = map)] #[rpc(name = "remoteDelete", params = "named")]
fn remote_delete( fn remote_delete(
&self, &self,
account: Option<String>, account: Option<String>,
@ -173,274 +112,140 @@ pub trait Rpc {
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool, #[allow(non_snake_case)] noteToSelf: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[allow(non_snake_case)] #[rpc(name = "send", params = "named")]
#[method(name = "send", param_kind = map)]
fn send( fn send(
&self, &self,
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] notifySelf: bool,
#[allow(non_snake_case)] noteToSelf: bool, #[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] endSession: bool, #[allow(non_snake_case)] endSession: bool,
message: String, message: String,
attachments: Vec<String>, attachments: Vec<String>,
#[allow(non_snake_case)] viewOnce: bool,
mentions: Vec<String>, mentions: Vec<String>,
#[allow(non_snake_case)] textStyle: Vec<String>,
#[allow(non_snake_case)] quoteTimestamp: Option<u64>, #[allow(non_snake_case)] quoteTimestamp: Option<u64>,
#[allow(non_snake_case)] quoteAuthor: Option<String>, #[allow(non_snake_case)] quoteAuthor: Option<String>,
#[allow(non_snake_case)] quoteMessage: Option<String>, #[allow(non_snake_case)] quoteMessage: Option<String>,
#[allow(non_snake_case)] quoteMention: Vec<String>, #[allow(non_snake_case)] quoteMention: Vec<String>,
#[allow(non_snake_case)] quoteTextStyle: Vec<String>,
#[allow(non_snake_case)] quoteAttachment: Vec<String>,
#[allow(non_snake_case)] previewUrl: Option<String>,
#[allow(non_snake_case)] previewTitle: Option<String>,
#[allow(non_snake_case)] previewDescription: Option<String>,
#[allow(non_snake_case)] previewImage: Option<String>,
sticker: Option<String>, sticker: Option<String>,
#[allow(non_snake_case)] storyTimestamp: Option<u64>, #[allow(non_snake_case)] storyTimestamp: Option<u64>,
#[allow(non_snake_case)] storyAuthor: Option<String>, #[allow(non_snake_case)] storyAuthor: Option<String>,
#[allow(non_snake_case)] editTimestamp: Option<u64>, ) -> Result<Value>;
#[allow(non_snake_case)] noUrgent: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)] #[rpc(name = "sendContacts", params = "named")]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; fn send_contacts(&self, account: Option<String>) -> Result<Value>;
#[method(name = "sendAdminDelete", param_kind = map)] #[rpc(name = "sendPaymentNotification", params = "named")]
fn send_admin_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
story: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPinMessage", param_kind = map)]
fn send_pin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] pinDuration: Option<i32>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollCreate", param_kind = map)]
fn send_poll_create(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
question: String,
option: Vec<String>,
#[allow(non_snake_case)] noMulti: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollVote", param_kind = map)]
fn send_poll_vote(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollAuthor: Option<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
option: Vec<i32>,
#[allow(non_snake_case)] voteCount: i32,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollTerminate", param_kind = map)]
fn send_poll_terminate(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendUnpinMessage", param_kind = map)]
fn send_unpin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification( fn send_payment_notification(
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
receipt: String, receipt: String,
note: String, note: String,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "sendReaction", param_kind = map)] #[rpc(name = "sendReaction", params = "named")]
fn send_reaction( fn send_reaction(
&self, &self,
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool, #[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
emoji: String, emoji: String,
#[allow(non_snake_case)] targetAuthor: String, #[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64, #[allow(non_snake_case)] targetTimestamp: u64,
remove: bool, remove: bool,
story: bool, story: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "sendReceipt", param_kind = map)] #[rpc(name = "sendReceipt", params = "named")]
fn send_receipt( fn send_receipt(
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
usernames: Vec<String>,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>, #[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String, r#type: String,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "sendSyncRequest", param_kind = map)] #[rpc(name = "sendSyncRequest", params = "named")]
fn send_sync_request(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; fn send_sync_request(&self, account: Option<String>) -> Result<Value>;
#[method(name = "sendTyping", param_kind = map)] #[rpc(name = "sendTyping", params = "named")]
fn send_typing( fn send_typing(
&self, &self,
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
stop: bool, stop: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "sendMessageRequestResponse", param_kind = map)] #[rpc(name = "setPin", params = "named")]
fn send_message_request_response( fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value>;
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "setPin", param_kind = map)] #[rpc(name = "submitRateLimitChallenge", params = "named")]
fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value, ErrorObjectOwned>;
#[method(name = "submitRateLimitChallenge", param_kind = map)]
fn submit_rate_limit_challenge( fn submit_rate_limit_challenge(
&self, &self,
account: Option<String>, account: Option<String>,
challenge: String, challenge: String,
captcha: String, captcha: String,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "startChangeNumber", param_kind = map)] #[rpc(name = "startLink", params = "named")]
fn start_change_number( fn start_link(&self, account: Option<String>) -> Result<JsonLink>;
&self,
account: Option<String>,
number: String,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startLink", param_kind = map)] #[rpc(name = "trust", params = "named")]
fn start_link(&self, account: Option<String>) -> Result<JsonLink, ErrorObjectOwned>;
#[method(name = "trust", param_kind = map)]
fn trust( fn trust(
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
#[allow(non_snake_case)] trustAllKnownKeys: bool, #[allow(non_snake_case)] trustAllKnownKeys: bool,
#[allow(non_snake_case)] verifiedSafetyNumber: Option<String>, #[allow(non_snake_case)] verifiedSafetyNumber: Option<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "unblock", param_kind = map)] #[rpc(name = "unblock", params = "named")]
fn unblock( fn unblock(
&self, &self,
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "unregister", param_kind = map)] #[rpc(name = "unregister", params = "named")]
fn unregister( fn unregister(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] deleteAccount: bool, #[allow(non_snake_case)] deleteAccount: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[allow(non_snake_case)] #[rpc(name = "updateAccount", params = "named")]
#[method(name = "updateAccount", param_kind = map)]
fn update_account( fn update_account(
&self, &self,
account: Option<String>, account: Option<String>,
deviceName: Option<String>, #[allow(non_snake_case)] deviceName: Option<String>,
unrestrictedUnidentifiedSender: Option<bool>, ) -> Result<Value>;
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
username: Option<String>,
deleteUsername: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)] #[rpc(name = "updateConfiguration", params = "named")]
fn update_configuration( fn update_configuration(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] readReceipts: Option<bool>, #[allow(non_snake_case)] readReceiptes: Option<bool>,
#[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>, #[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>,
#[allow(non_snake_case)] typingIndicators: Option<bool>, #[allow(non_snake_case)] typingIndicators: Option<bool>,
#[allow(non_snake_case)] linkPreviews: Option<bool>, #[allow(non_snake_case)] linkPreviews: Option<bool>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "updateContact", param_kind = map)] #[rpc(name = "updateContact", params = "named")]
fn update_contact( fn update_contact(
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
name: Option<String>, name: Option<String>,
expiration: Option<u32>, expiration: Option<u32>,
#[allow(non_snake_case)] givenName: Option<String>, ) -> Result<Value>;
#[allow(non_snake_case)] familyName: Option<String>,
#[allow(non_snake_case)] nickGivenName: Option<String>,
#[allow(non_snake_case)] nickFamilyName: Option<String>,
note: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateDevice", param_kind = map)] #[rpc(name = "updateGroup", params = "named")]
fn update_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]
fn update_group( fn update_group(
&self, &self,
account: Option<String>, account: Option<String>,
@ -460,11 +265,9 @@ pub trait Rpc {
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>, #[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>, #[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>, expiration: Option<u32>,
#[allow(non_snake_case)] memberLabelEmoji: Option<String>, ) -> Result<Value>;
#[allow(non_snake_case)] memberLabel: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)] #[rpc(name = "updateProfile", params = "named")]
fn update_profile( fn update_profile(
&self, &self,
account: Option<String>, account: Option<String>,
@ -475,33 +278,32 @@ pub trait Rpc {
#[allow(non_snake_case)] mobileCoinAddress: Option<String>, #[allow(non_snake_case)] mobileCoinAddress: Option<String>,
avatar: Option<String>, avatar: Option<String>,
#[allow(non_snake_case)] removeAvatar: bool, #[allow(non_snake_case)] removeAvatar: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[method(name = "uploadStickerPack", param_kind = map)] #[rpc(name = "uploadStickerPack", params = "named")]
fn upload_sticker_pack( fn upload_sticker_pack(&self, account: Option<String>, path: String) -> Result<Value>;
&self,
account: Option<String>,
path: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "verify", param_kind = map)] #[rpc(name = "verify", params = "named")]
fn verify( fn verify(
&self, &self,
account: Option<String>, account: Option<String>,
#[allow(non_snake_case)] verificationCode: String, #[allow(non_snake_case)] verificationCode: String,
pin: Option<String>, pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value>;
#[subscription( #[pubsub(
name = "subscribeReceive" => "receive", subscription = "receive",
unsubscribe = "unsubscribeReceive", subscribe,
item = Value, name = "subscribeReceive",
param_kind = map params = "named"
)] )]
async fn subscribe_receive(&self, account: Option<String>) -> SubscriptionResult; fn subscribe_receive(&self, _: Self::Metadata, _: Subscriber<Value>, account: Option<String>);
#[method(name = "version")] #[pubsub(subscription = "receive", unsubscribe, name = "unsubscribeReceive")]
fn version(&self) -> Result<Value, ErrorObjectOwned>; fn unsubscribe_receive(&self, _: Option<Self::Metadata>, _: SubscriptionId) -> Result<bool>;
#[rpc(name = "version")]
fn version(&self) -> Result<Value>;
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -510,23 +312,10 @@ pub struct JsonLink {
pub device_link_uri: String, pub device_link_uri: String,
} }
pub async fn connect_tcp( pub async fn connect_tcp(tcp: impl ToSocketAddrs) -> Result<SignalCliClient, RpcError> {
tcp: impl ToSocketAddrs, super::tcp::connect::<_, SignalCliClient>(tcp).await
) -> Result<impl SubscriptionClientT, std::io::Error> {
let (sender, receiver) = super::transports::tcp::connect(tcp).await?;
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
} }
#[cfg(unix)] pub async fn connect_unix(socket_path: impl AsRef<Path>) -> Result<SignalCliClient, RpcError> {
pub async fn connect_unix( ipc::connect::<_, SignalCliClient>(socket_path).await
socket_path: impl AsRef<Path>,
) -> Result<impl SubscriptionClientT, std::io::Error> {
let (sender, receiver) = super::transports::ipc::connect(socket_path).await?;
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
}
pub async fn connect_http(uri: &str) -> Result<impl SubscriptionClientT + use<>, Error> {
HttpClientBuilder::default().build(uri)
} }

View File

@ -1,112 +1,85 @@
use std::{path::PathBuf, time::Duration};
use clap::Parser; use clap::Parser;
use jsonrpsee::core::client::{Error as RpcError, Subscription, SubscriptionClientT}; use jsonrpc_client_transports::{RpcError, TypedSubscriptionStream};
use serde_json::{Error, Value}; use jsonrpc_core::{futures_util::StreamExt, Value};
use std::{path::PathBuf, time::Duration};
use tokio::{select, time::sleep}; use tokio::{select, time::sleep};
use cli::Cli; use crate::cli::{GroupPermission, LinkState};
use crate::cli::{CliCommands, GroupPermission, LinkState};
use crate::jsonrpc::RpcClient;
mod cli; mod cli;
#[allow(non_snake_case, clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
mod jsonrpc; mod jsonrpc;
mod transports; mod tcp;
const DEFAULT_TCP: &str = "127.0.0.1:7583"; const DEFAULT_TCP: &str = "127.0.0.1:7583";
const DEFAULT_SOCKET_SUFFIX: &str = "signal-cli/socket"; const DEFAULT_SOCKET_SUFFIX: &str = "signal-cli/socket";
const DEFAULT_HTTP: &str = "http://localhost:8080/api/v1/rpc";
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), anyhow::Error> { async fn main() -> Result<(), anyhow::Error> {
let cli = cli::Cli::parse(); let cli = cli::Cli::parse();
let result = connect(cli).await; let client = connect(&cli)
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to socket: {e}"))?;
match result { let result = match cli.command {
Ok(Value::Null) => {} cli::CliCommands::Receive { timeout } => {
Ok(v) => println!("{v}"), let mut stream = client
Err(e) => return Err(anyhow::anyhow!("JSON-RPC command failed: {e:?}")), .subscribe_receive(cli.account)
} .map_err(|e| anyhow::anyhow!("JSON-RPC command failed: {:?}", e))?;
Ok(())
}
async fn handle_command(
cli: Cli,
client: impl SubscriptionClientT + Sync,
) -> Result<Value, RpcError> {
match cli.command {
CliCommands::Receive { timeout } => {
let mut stream = client.subscribe_receive(cli.account).await?;
{ {
while let Some(v) = stream_next(timeout, &mut stream).await { while let Some(v) = stream_next(timeout, &mut stream).await {
let v = v?; let v = v.map_err(|e| anyhow::anyhow!("JSON-RPC command failed: {:?}", e))?;
println!("{v}"); println!("{v}");
} }
} }
stream.unsubscribe().await?; return Ok(());
Ok(Value::Null)
} }
CliCommands::AddDevice { uri } => client.add_device(cli.account, uri).await, cli::CliCommands::AddDevice { uri } => client.add_device(cli.account, uri).await,
CliCommands::Block { cli::CliCommands::Block {
recipient, recipient,
group_id, group_id,
} => client.block(cli.account, recipient, group_id).await, } => client.block(cli.account, recipient, group_id).await,
CliCommands::DeleteLocalAccountData { ignore_registered } => { cli::CliCommands::DeleteLocalAccountData { ignore_registered } => {
client client
.delete_local_account_data(cli.account, ignore_registered) .delete_local_account_data(cli.account, ignore_registered)
.await .await
} }
CliCommands::GetUserStatus { cli::CliCommands::GetUserStatus { recipient } => {
recipient, client.get_user_status(cli.account, recipient).await
username,
} => {
client
.get_user_status(cli.account, recipient, username)
.await
} }
CliCommands::JoinGroup { uri } => client.join_group(cli.account, uri).await, cli::CliCommands::JoinGroup { uri } => client.join_group(cli.account, uri).await,
CliCommands::Link { name } => { cli::CliCommands::Link { name } => {
let url = client let url = client
.start_link(cli.account) .start_link(cli.account)
.await .await
.map_err(|e| RpcError::Custom(format!("JSON-RPC command startLink failed: {e:?}")))? .map_err(|e| anyhow::anyhow!("JSON-RPC command startLink failed: {e:?}",))?
.device_link_uri; .device_link_uri;
println!("{url}"); println!("{}", url);
client.finish_link(url, name).await client.finish_link(url, name).await
} }
CliCommands::ListAccounts => client.list_accounts().await, cli::CliCommands::ListAccounts => client.list_accounts().await,
CliCommands::ListContacts { cli::CliCommands::ListContacts {
recipient, recipient,
all_recipients, all_recipients,
blocked, blocked,
name, name,
detailed,
internal,
} => { } => {
client client
.list_contacts( .list_contacts(cli.account, recipient, all_recipients, blocked, name)
cli.account,
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
)
.await .await
} }
CliCommands::ListDevices => client.list_devices(cli.account).await, cli::CliCommands::ListDevices => client.list_devices(cli.account).await,
CliCommands::ListGroups { cli::CliCommands::ListGroups {
detailed: _, detailed: _,
group_id, group_id,
} => client.list_groups(cli.account, group_id).await, } => client.list_groups(cli.account, group_id).await,
CliCommands::ListIdentities { number } => client.list_identities(cli.account, number).await, cli::CliCommands::ListIdentities { number } => {
CliCommands::ListStickerPacks => client.list_sticker_packs(cli.account).await, client.list_identities(cli.account, number).await
CliCommands::QuitGroup { }
cli::CliCommands::ListStickerPacks => client.list_sticker_packs(cli.account).await,
cli::CliCommands::QuitGroup {
group_id, group_id,
delete, delete,
admin, admin,
@ -115,29 +88,17 @@ async fn handle_command(
.quit_group(cli.account, group_id, delete, admin) .quit_group(cli.account, group_id, delete, admin)
.await .await
} }
CliCommands::Register { cli::CliCommands::Register { voice, captcha } => {
voice, client.register(cli.account, voice, captcha).await
captcha,
reregister,
} => {
client
.register(cli.account, voice, captcha, reregister)
.await
} }
CliCommands::RemoveContact { cli::CliCommands::RemoveContact { recipient, forget } => {
recipient, client.remove_contact(cli.account, recipient, forget).await
forget,
hide,
} => {
client
.remove_contact(cli.account, recipient, forget, hide)
.await
} }
CliCommands::RemoveDevice { device_id } => { cli::CliCommands::RemoveDevice { device_id } => {
client.remove_device(cli.account, device_id).await client.remove_device(cli.account, device_id).await
} }
CliCommands::RemovePin => client.remove_pin(cli.account).await, cli::CliCommands::RemovePin => client.remove_pin(cli.account).await,
CliCommands::RemoteDelete { cli::CliCommands::RemoteDelete {
target_timestamp, target_timestamp,
recipient, recipient,
group_id, group_id,
@ -153,91 +114,44 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::Send { cli::CliCommands::Send {
recipient, recipient,
group_id, group_id,
username,
notify_self,
note_to_self, note_to_self,
end_session, end_session,
message, message,
message_from_stdin,
attachment, attachment,
view_once,
mention, mention,
text_style,
quote_timestamp, quote_timestamp,
quote_author, quote_author,
quote_message, quote_message,
quote_mention, quote_mention,
quote_text_style,
quote_attachment,
preview_url,
preview_title,
preview_description,
preview_image,
sticker, sticker,
story_timestamp, story_timestamp,
story_author, story_author,
edit_timestamp,
no_urgent,
} => { } => {
client client
.send( .send(
cli.account, cli.account,
recipient, recipient,
group_id, group_id,
username,
notify_self,
note_to_self, note_to_self,
end_session, end_session,
if message_from_stdin { message.unwrap_or_default(),
std::io::read_to_string(std::io::stdin()).unwrap()
} else {
message.unwrap_or_default()
},
attachment, attachment,
view_once,
mention, mention,
text_style,
quote_timestamp, quote_timestamp,
quote_author, quote_author,
quote_message, quote_message,
quote_mention, quote_mention,
quote_text_style,
quote_attachment,
preview_url,
preview_title,
preview_description,
preview_image,
sticker, sticker,
story_timestamp, story_timestamp,
story_author, story_author,
edit_timestamp,
no_urgent,
) )
.await .await
} }
CliCommands::SendContacts => client.send_contacts(cli.account).await, cli::CliCommands::SendContacts => client.send_contacts(cli.account).await,
CliCommands::SendAdminDelete { cli::CliCommands::SendPaymentNotification {
group_id,
target_author,
target_timestamp,
story,
notify_self,
} => {
client
.send_admin_delete(
cli.account,
group_id,
target_author,
target_timestamp,
story,
notify_self,
)
.await
}
CliCommands::SendPaymentNotification {
recipient, recipient,
receipt, receipt,
note, note,
@ -246,108 +160,10 @@ async fn handle_command(
.send_payment_notification(cli.account, recipient, receipt, note) .send_payment_notification(cli.account, recipient, receipt, note)
.await .await
} }
CliCommands::SendPinMessage { cli::CliCommands::SendReaction {
recipient, recipient,
group_id, group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self, note_to_self,
notify_self,
story,
} => {
client
.send_pin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SendPollCreate {
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
} => {
client
.send_poll_create(
cli.account,
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollTerminate {
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
} => {
client
.send_poll_terminate(
cli.account,
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollVote {
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
} => {
client
.send_poll_vote(
cli.account,
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendReaction {
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji, emoji,
target_author, target_author,
target_timestamp, target_timestamp,
@ -359,9 +175,7 @@ async fn handle_command(
cli.account, cli.account,
recipient, recipient,
group_id, group_id,
username,
note_to_self, note_to_self,
notify_self,
emoji, emoji,
target_author, target_author,
target_timestamp, target_timestamp,
@ -370,9 +184,8 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::SendReceipt { cli::CliCommands::SendReceipt {
recipient, recipient,
username,
target_timestamp, target_timestamp,
r#type, r#type,
} => { } => {
@ -380,7 +193,6 @@ async fn handle_command(
.send_receipt( .send_receipt(
cli.account, cli.account,
recipient, recipient,
username,
target_timestamp, target_timestamp,
match r#type { match r#type {
cli::ReceiptType::Read => "read".to_owned(), cli::ReceiptType::Read => "read".to_owned(),
@ -389,8 +201,8 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::SendSyncRequest => client.send_sync_request(cli.account).await, cli::CliCommands::SendSyncRequest => client.send_sync_request(cli.account).await,
CliCommands::SendTyping { cli::CliCommands::SendTyping {
recipient, recipient,
group_id, group_id,
stop, stop,
@ -399,37 +211,13 @@ async fn handle_command(
.send_typing(cli.account, recipient, group_id, stop) .send_typing(cli.account, recipient, group_id, stop)
.await .await
} }
CliCommands::SendUnpinMessage { cli::CliCommands::SetPin { pin } => client.set_pin(cli.account, pin).await,
recipient, cli::CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
} => {
client
.send_unpin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SetPin { pin } => client.set_pin(cli.account, pin).await,
CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
client client
.submit_rate_limit_challenge(cli.account, challenge, captcha) .submit_rate_limit_challenge(cli.account, challenge, captcha)
.await .await
} }
CliCommands::Trust { cli::CliCommands::Trust {
recipient, recipient,
trust_all_known_keys, trust_all_known_keys,
verified_safety_number, verified_safety_number,
@ -443,34 +231,17 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::Unblock { cli::CliCommands::Unblock {
recipient, recipient,
group_id, group_id,
} => client.unblock(cli.account, recipient, group_id).await, } => client.unblock(cli.account, recipient, group_id).await,
CliCommands::Unregister { delete_account } => { cli::CliCommands::Unregister { delete_account } => {
client.unregister(cli.account, delete_account).await client.unregister(cli.account, delete_account).await
} }
CliCommands::UpdateAccount { cli::CliCommands::UpdateAccount { device_name } => {
device_name, client.update_account(cli.account, device_name).await
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
} => {
client
.update_account(
cli.account,
device_name,
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
)
.await
} }
CliCommands::UpdateConfiguration { cli::CliCommands::UpdateConfiguration {
read_receipts, read_receipts,
unidentified_delivery_indicators, unidentified_delivery_indicators,
typing_indicators, typing_indicators,
@ -486,39 +257,16 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::UpdateContact { cli::CliCommands::UpdateContact {
recipient, recipient,
expiration, expiration,
name, name,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
} => { } => {
client client
.update_contact( .update_contact(cli.account, recipient, name, expiration)
cli.account,
recipient,
name,
expiration,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
)
.await .await
} }
CliCommands::UpdateDevice { cli::CliCommands::UpdateGroup {
device_id,
device_name,
} => {
client
.update_device(cli.account, device_id, device_name)
.await
}
CliCommands::UpdateGroup {
group_id, group_id,
name, name,
description, description,
@ -535,8 +283,6 @@ async fn handle_command(
set_permission_edit_details, set_permission_edit_details,
set_permission_send_messages, set_permission_send_messages,
expiration, expiration,
member_label_emoji,
member_label,
} => { } => {
client client
.update_group( .update_group(
@ -570,12 +316,10 @@ async fn handle_command(
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(), GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}), }),
expiration, expiration,
member_label_emoji,
member_label,
) )
.await .await
} }
CliCommands::UpdateProfile { cli::CliCommands::UpdateProfile {
given_name, given_name,
family_name, family_name,
about, about,
@ -597,101 +341,27 @@ async fn handle_command(
) )
.await .await
} }
CliCommands::UploadStickerPack { path } => { cli::CliCommands::UploadStickerPack { path } => {
client.upload_sticker_pack(cli.account, path).await client.upload_sticker_pack(cli.account, path).await
} }
CliCommands::Verify { cli::CliCommands::Verify {
verification_code, verification_code,
pin, pin,
} => client.verify(cli.account, verification_code, pin).await, } => client.verify(cli.account, verification_code, pin).await,
CliCommands::Version => client.version().await, cli::CliCommands::Version => client.version().await,
CliCommands::AddStickerPack { uri } => client.add_sticker_pack(cli.account, uri).await, };
CliCommands::FinishChangeNumber {
number, result
verification_code, .map(|v| println!("{v}"))
pin, .map_err(|e| anyhow::anyhow!("JSON-RPC command failed: {e:?}",))?;
} => { Ok(())
client
.finish_change_number(cli.account, number, verification_code, pin)
.await
}
CliCommands::GetAttachment {
id,
recipient,
group_id,
} => {
client
.get_attachment(cli.account, id, recipient, group_id)
.await
}
CliCommands::GetAvatar {
contact,
profile,
group_id,
} => {
client
.get_avatar(cli.account, contact, profile, group_id)
.await
}
CliCommands::GetSticker {
pack_id,
sticker_id,
} => client.get_sticker(cli.account, pack_id, sticker_id).await,
CliCommands::StartChangeNumber {
number,
voice,
captcha,
} => {
client
.start_change_number(cli.account, number, voice, captcha)
.await
}
CliCommands::SendMessageRequestResponse {
recipient,
group_id,
r#type,
} => {
client
.send_message_request_response(
cli.account,
recipient,
group_id,
match r#type {
cli::MessageRequestResponseType::Accept => "accept".to_owned(),
cli::MessageRequestResponseType::Delete => "delete".to_owned(),
},
)
.await
}
}
} }
async fn connect(cli: Cli) -> Result<Value, RpcError> { async fn connect(cli: &cli::Cli) -> Result<jsonrpc::SignalCliClient, RpcError> {
if let Some(http) = &cli.json_rpc_http { if let Some(tcp) = cli.json_rpc_tcp {
let uri = if let Some(uri) = http {
uri
} else {
DEFAULT_HTTP
};
let client = jsonrpc::connect_http(uri)
.await
.map_err(|e| RpcError::Custom(format!("Failed to connect to socket: {e}")))?;
handle_command(cli, client).await
} else if let Some(tcp) = cli.json_rpc_tcp {
let socket_addr = tcp.unwrap_or_else(|| DEFAULT_TCP.parse().unwrap()); let socket_addr = tcp.unwrap_or_else(|| DEFAULT_TCP.parse().unwrap());
let client = jsonrpc::connect_tcp(socket_addr) jsonrpc::connect_tcp(socket_addr).await
.await
.map_err(|e| RpcError::Custom(format!("Failed to connect to socket: {e}")))?;
handle_command(cli, client).await
} else { } else {
#[cfg(windows)]
{
Err(RpcError::Custom("Invalid socket".into()))
}
#[cfg(unix)]
{
let socket_path = cli let socket_path = cli
.json_rpc_socket .json_rpc_socket
.clone() .clone()
@ -704,19 +374,14 @@ async fn connect(cli: Cli) -> Result<Value, RpcError> {
}) })
}) })
.unwrap_or_else(|| ("/run".to_owned() + DEFAULT_SOCKET_SUFFIX).into()); .unwrap_or_else(|| ("/run".to_owned() + DEFAULT_SOCKET_SUFFIX).into());
let client = jsonrpc::connect_unix(socket_path) jsonrpc::connect_unix(socket_path).await
.await
.map_err(|e| RpcError::Custom(format!("Failed to connect to socket: {e}")))?;
handle_command(cli, client).await
}
} }
} }
async fn stream_next( async fn stream_next(
timeout: f64, timeout: f64,
stream: &mut Subscription<Value>, stream: &mut TypedSubscriptionStream<Value>,
) -> Option<Result<Value, Error>> { ) -> Option<Result<Value, RpcError>> {
if timeout < 0.0 { if timeout < 0.0 {
stream.next().await stream.next().await
} else { } else {

29
client/src/tcp.rs Normal file
View File

@ -0,0 +1,29 @@
use jsonrpc_client_transports::{transports::duplex, RpcChannel, RpcError};
use jsonrpc_core::futures_util::{SinkExt, StreamExt, TryStreamExt};
use jsonrpc_server_utils::{codecs::StreamCodec, tokio_util::codec::Decoder};
use tokio::net::{TcpStream, ToSocketAddrs};
/// Connect to a JSON-RPC TCP server.
pub async fn connect<S: ToSocketAddrs, Client: From<RpcChannel>>(
socket: S,
) -> Result<Client, RpcError> {
let connection = TcpStream::connect(socket)
.await
.map_err(|e| RpcError::Other(Box::new(e)))?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sink = sink.sink_map_err(|e| RpcError::Other(Box::new(e)));
let stream = stream.map_err(|e| log::error!("TCP stream error: {}", e));
let (client, sender) = duplex(
Box::pin(sink),
Box::pin(
stream
.take_while(|x| std::future::ready(x.is_ok()))
.map(|x| x.expect("Stream is closed upon first error.")),
),
);
tokio::spawn(client);
Ok(sender.into())
}

View File

@ -1,23 +0,0 @@
use std::io::Error;
use std::path::Path;
use futures_util::stream::StreamExt;
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
use tokio::net::UnixStream;
use tokio_util::codec::Decoder;
use super::stream_codec::StreamCodec;
use super::{Receiver, Sender};
/// Connect to a JSON-RPC Unix Socket server.
pub async fn connect(
socket: impl AsRef<Path>,
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
let connection = UnixStream::connect(socket).await?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sender = Sender { inner: sink };
let receiver = Receiver { inner: stream };
Ok((sender, receiver))
}

View File

@ -1,60 +0,0 @@
use futures_util::{stream::StreamExt, Sink, SinkExt, Stream};
use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT};
use thiserror::Error;
#[cfg(unix)]
pub mod ipc;
mod stream_codec;
pub mod tcp;
#[derive(Debug, Error)]
enum Errors {
#[error("Other: {0}")]
Other(String),
#[error("Closed")]
Closed,
}
struct Sender<T: Send + Sink<String>> {
inner: T,
}
impl<T: Send + Sink<String, Error = impl std::error::Error> + Unpin + 'static> TransportSenderT
for Sender<T>
{
type Error = Errors;
async fn send(&mut self, body: String) -> Result<(), Self::Error> {
self.inner
.send(body)
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
async fn close(&mut self) -> Result<(), Self::Error> {
self.inner
.close()
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
}
struct Receiver<T: Send + Stream> {
inner: T,
}
impl<T: Send + Stream<Item = Result<String, std::io::Error>> + Unpin + 'static> TransportReceiverT
for Receiver<T>
{
type Error = Errors;
async fn receive(&mut self) -> Result<ReceivedMessage, Self::Error> {
match self.inner.next().await {
None => Err(Errors::Closed),
Some(Ok(msg)) => Ok(ReceivedMessage::Text(msg)),
Some(Err(e)) => Err(Errors::Other(format!("{e:?}"))),
}
}
}

View File

@ -1,61 +0,0 @@
use bytes::BytesMut;
use std::{io, str};
use tokio_util::codec::{Decoder, Encoder};
type Separator = u8;
/// Stream codec for streaming protocols (ipc, tcp)
#[derive(Debug, Default)]
pub struct StreamCodec {
incoming_separator: Separator,
outgoing_separator: Separator,
}
impl StreamCodec {
/// Default codec with streaming input data. Input can be both enveloped and not.
pub fn stream_incoming() -> Self {
StreamCodec::new(b'\n', b'\n')
}
/// New custom stream codec
pub fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self {
StreamCodec {
incoming_separator,
outgoing_separator,
}
}
}
impl Decoder for StreamCodec {
type Item = String;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Self::Item>> {
if let Some(i) = buf
.as_ref()
.iter()
.position(|&b| b == self.incoming_separator)
{
let line = buf.split_to(i);
let _ = buf.split_to(1);
match str::from_utf8(line.as_ref()) {
Ok(s) => Ok(Some(s.to_string())),
Err(_) => Err(io::Error::other("invalid UTF-8")),
}
} else {
Ok(None)
}
}
}
impl Encoder<String> for StreamCodec {
type Error = io::Error;
fn encode(&mut self, msg: String, buf: &mut BytesMut) -> io::Result<()> {
let mut payload = msg.into_bytes();
payload.push(self.outgoing_separator);
buf.extend_from_slice(&payload);
Ok(())
}
}

View File

@ -1,22 +0,0 @@
use std::io::Error;
use futures_util::stream::StreamExt;
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio_util::codec::Decoder;
use super::stream_codec::StreamCodec;
use super::{Receiver, Sender};
/// Connect to a JSON-RPC TCP server.
pub async fn connect(
socket: impl ToSocketAddrs,
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
let connection = TcpStream::connect(socket).await?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sender = Sender { inner: sink };
let receiver = Receiver { inner: stream };
Ok((sender, receiver))
}

View File

@ -1,154 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>org.asamk.SignalCli</id>
<name>signal-cli</name>
<summary>Use Signal messenger in terminal</summary>
<developer id="org.asamk">
<name>AsamK</name>
</developer>
<icon type="stock">org.asamk.SignalCli</icon>
<keywords>
<keyword>signal</keyword>
<keyword>signal-cli</keyword>
<keyword>messenger</keyword>
<keyword>messaging</keyword>
</keywords>
<url type="bugtracker">https://github.com/AsamK/signal-cli/issues</url>
<url type="homepage">https://github.com/AsamK/signal-cli</url>
<url type="donation">https://github.com/sponsors/AsamK</url>
<url type="faq">https://github.com/AsamK/signal-cli/discussions</url>
<url type="vcs-browser">https://github.com/AsamK/signal-cli</url>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-only</project_license>
<description>
<p>
signal-cli is an unofficial commandline interface for the Signal Messenger.
It supports many Signal functions, including registering, verifying, sending and receiving messages.
For registering you need a phone number where you can receive SMS or incoming calls.
Alternatively signal-cli can be linked to an existing App account.
</p>
</description>
<categories>
<category>Utility</category>
<category>Java</category>
</categories>
<provides>
<binary>signal-cli</binary>
</provides>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.7" date="2026-08-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.7</url>
</release>
<release version="0.14.6" date="2026-07-12">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url>
</release>
<release version="0.14.5" date="2026-06-11">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.5</url>
</release>
<release version="0.14.4" date="2026-05-23">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.4</url>
</release>
<release version="0.14.3" date="2026-04-22">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.3</url>
</release>
<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>
<release version="0.14.0" date="2026-03-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url>
</release>
<release version="0.13.24" date="2026-02-05">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.24</url>
</release>
<release version="0.13.23" date="2026-01-24">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.23</url>
</release>
<release version="0.13.22" date="2025-11-14">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.22</url>
</release>
<release version="0.13.21" date="2025-10-25">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.21</url>
</release>
<release version="0.13.20" date="2025-09-23">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.20</url>
</release>
<release version="0.13.19" date="2025-09-15">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.19</url>
</release>
<release version="0.13.18" date="2025-07-16">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.18</url>
</release>
<release version="0.13.17" date="2025-06-28">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.17</url>
</release>
<release version="0.13.16" date="2025-06-07">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.16</url>
</release>
<release version="0.13.15" date="2025-05-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.15</url>
</release>
<release version="0.13.14" date="2025-04-06">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.14</url>
</release>
<release version="0.13.13" date="2025-02-28">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.13</url>
</release>
<release version="0.13.12" date="2025-01-18">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.12</url>
</release>
<release version="0.13.11" date="2024-12-26">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.11</url>
</release>
<release version="0.13.10" date="2024-11-30">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.10</url>
</release>
<release version="0.13.9" date="2024-10-28">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.9</url>
</release>
<release version="0.13.8" date="2024-10-26">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.8</url>
</release>
<release version="0.13.7" date="2024-09-28">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.7</url>
</release>
<release version="0.13.6" date="2024-09-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.6</url>
</release>
<release version="0.13.5" date="2024-07-25">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.5</url>
</release>
<release version="0.13.4" date="2024-06-06">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.4</url>
</release>
<release version="0.13.3" date="2024-04-19">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.3</url>
</release>
<release version="0.13.2" date="2024-03-23">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.2</url>
</release>
<release version="0.13.1" date="2024-02-27">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.1</url>
</release>
<release version="0.13.0" date="2024-02-18">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.0</url>
</release>
<release version="0.12.8" date="2024-02-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.12.8</url>
</release>
<release version="0.12.7" date="2023-12-15">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.12.7</url>
</release>
</releases>
</component>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" version="1.1" viewBox="0 0 33.867 33.867" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-32.279 -138.64)">
<g transform="matrix(.45526 0 0 .45526 33.984 140.17)">
<path d="m33.468 66.938c-18.454 0-33.468-15.014-33.468-33.469s15.014-33.469 33.468-33.469c18.455 0 33.469 15.014 33.469 33.469 0 5.621-1.421 11.161-4.116 16.076l4.608 17.2-16.849-4.516c-5.172 3.084-11.069 4.709-17.112 4.709z" fill="#fff"/>
<path d="m33.468 67.184c-18.454 0-33.468-15.014-33.468-33.469s15.014-33.469 33.468-33.469c18.455 0 33.469 15.014 33.469 33.469 0 5.621-1.421 11.161-4.116 16.076l4.608 17.2-16.849-4.516c-5.172 3.084-11.069 4.709-17.112 4.709zm0-62.938c-16.249 0-29.468 13.22-29.468 29.469s13.219 29.469 29.468 29.469c5.582 0 11.021-1.574 15.729-4.554l0.74-0.468 11.835 3.171-3.243-12.1 0.419-0.72c2.609-4.484 3.988-9.602 3.988-14.799 0-16.248-13.219-29.468-29.468-29.468z"/>
<path d="m25.515 45.296q-2.3937 0-4.2817-0.97772-1.8543-0.97772-2.9332-3.0343-1.0451-2.0566-1.0451-5.2595 0-3.3377 1.1126-5.428 1.1126-2.0903 3.0006-3.068 1.9217-0.97772 4.3492-0.97772 1.3823 0 2.6634 0.30343 1.2812 0.26972 2.0903 0.67429l-0.91029 2.4612q-0.80915-0.30343-1.888-0.57315t-2.0229-0.26972q-5.3269 0-5.3269 6.844 0 3.2703 1.2812 5.0235 1.3149 1.7194 3.8772 1.7194 1.4834 0 2.596-0.30343 1.1463-0.30343 2.0903-0.74172v2.6297q-0.91029 0.472-2.0229 0.708-1.0789 0.26972-2.6297 0.26972zm11.901-0.33714h-2.9669v-25.623h2.9669zm7.2486-24.848q0.67429 0 1.18 0.472 0.53943 0.43829 0.53943 1.416 0 0.94401-0.53943 1.416-0.50572 0.472-1.18 0.472-0.74172 0-1.2474-0.472-0.50572-0.472-0.50572-1.416 0-0.97772 0.50572-1.416 0.50572-0.472 1.2474-0.472zm1.4497 6.7766v18.071h-2.9669v-18.071z" aria-label="cli"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -32,6 +32,8 @@ RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true RestrictNamespaces=true
RestrictRealtime=true RestrictRealtime=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
# JVM always exits with 143 in reaction to SIGTERM signal
SuccessExitStatus=143
StandardInput=socket StandardInput=socket
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal

View File

@ -8,9 +8,11 @@ After=network-online.target
[Service] [Service]
Type=dbus Type=dbus
Environment="SIGNAL_CLI_OPTS=-Xms2m" Environment="SIGNAL_CLI_OPTS=-Xms2m"
ExecStart=%dir%/bin/signal-cli --config /var/lib/signal-cli daemon --dbus-system ExecStart=%dir%/bin/signal-cli --config /var/lib/signal-cli daemon --system
User=signal-cli User=signal-cli
BusName=org.asamk.Signal BusName=org.asamk.Signal
# JVM always exits with 143 in reaction to SIGTERM signal
SuccessExitStatus=143
[Install] [Install]
Alias=dbus-org.asamk.Signal.service Alias=dbus-org.asamk.Signal.service

View File

@ -8,9 +8,11 @@ After=network-online.target
[Service] [Service]
Type=dbus Type=dbus
Environment="SIGNAL_CLI_OPTS=-Xms2m" Environment="SIGNAL_CLI_OPTS=-Xms2m"
ExecStart=%dir%/bin/signal-cli -a %I --config /var/lib/signal-cli daemon --dbus-system ExecStart=%dir%/bin/signal-cli -a %I --config /var/lib/signal-cli daemon --system
User=signal-cli User=signal-cli
BusName=org.asamk.Signal BusName=org.asamk.Signal
# JVM always exits with 143 in reaction to SIGTERM signal
SuccessExitStatus=143
[Install] [Install]
Alias=dbus-org.asamk.Signal.service Alias=dbus-org.asamk.Signal.service

View File

@ -1,359 +0,0 @@
# Voice Call Support
## Overview
signal-cli supports voice calls by spawning a subprocess called
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and
audio transport. signal-cli communicates with the tunnel over its stdin/stdout
using newline-delimited JSON messages, relaying signaling between the tunnel
and the Signal protocol.
```
signal-cli signal-call-tunnel
| |
|-- spawn --------------------------->|
|-- config JSON on stdin ------------>|
| |
|-- commands on stdin --------------->|
|<-- events on stdout ----------------|
| | WebRTC
| signaling relay | audio I/O
| |
| (stderr: tunnel logging) -------->| (captured by signal-cli)
```
Each call gets its own tunnel process. When the call ends, signal-cli closes
stdin and destroys the process.
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
returned by the tunnel in its `ready` message. signal-cli passes them through
to JSON-RPC clients, which use them to connect audio via platform APIs.
---
## Spawning the Tunnel
For each call, signal-cli:
1. Spawns `signal-call-tunnel`
2. Writes config JSON followed by a newline to stdin
3. Keeps stdin open for subsequent control messages
4. Reads control events from stdout
5. Captures stderr for logging
The `signal-call-tunnel` binary is located by searching (in order):
1. `SIGNAL_CALL_TUNNEL_BIN` environment variable
2. `<signal-cli install dir>/bin/signal-call-tunnel` (detected from jar location)
3. `signal-call-tunnel` on `PATH`
### Config JSON
The first line written to the tunnel's stdin:
```json
{
"call_id": 12345,
"is_outgoing": true,
"local_device_id": 1,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}
```
| Field | Type | Description |
|----------------------|-------------------------|-----------------------------------------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
If `input_device_name` or `output_device_name` are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
`signal_input_<call_id>`). On macOS, these are the fixed names `signal_input`
and `signal_output`, which must match the pre-installed BlackHole drivers.
---
## Control Protocol
Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout
(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent
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)* |
### 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` |
Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
```json
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
```
---
## Startup Sequence
```
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
|-- config JSON + newline on stdin ---->|
| | parse config
| | initialize audio
| |
|<-------- ready (on stdout) -----------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
| |
|-- control messages on stdin --------->|
|<-- control events on stdout ----------|
```
---
## Call Flows
### Outgoing call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| |
| | create offer |
|<-- sendOffer ------------| |
|-- offer via Signal -------------------------------->|
|<-- answer via Signal -------------------------------|
|-- receivedAnswer ------->| (+ identity keys) |
|<-- sendIce --------------| |
|-- ICE via Signal -------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connects |
|<-- stateChange:Connected | |
```
### Incoming call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|<-- offer via Signal --------------------------------|
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| |
| | process offer |
|<-- sendAnswer -----------| |
|-- answer via Signal -------------------------------->|
|<-- sendIce --------------| |
|-- ICE via Signal ------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connecting... |
| | |
| (user accepts call) | |
| Java defers accept | |
| | |
|<-- stateChange:Ringing --| (tunnel ready to accept)|
|-- accept --------------->| (deferred accept sent) |
| | accept |
|<-- stateChange:Connected | |
```
### JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
**Important:** Call event notifications are not sent by default. Clients must
call `subscribeCallEvents` before initiating or receiving calls. Without this,
incoming calls are silently ignored (no tunnel is spawned).
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (required: enables call support)
| |
|-- startCall(recipient) ------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: RINGING_OUTGOING ------|
| ... remote answers ... |
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
| |
|-- hangupCall(callId) --------------->| (or: receive callEvent ENDED)
|<-- callEvent: ENDED -----------------|
| disconnect from audio devices |
```
For incoming calls:
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (if not already subscribed)
| |
|<-- callEvent: RINGING_INCOMING ------| (includes callId, device names)
| |
|-- acceptCall(callId) --------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: CONNECTING ------------|
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
```
To stop receiving call events, call `unsubscribeCallEvents`.
---
## State Machine
Call states as seen by JSON-RPC clients:
```
startCall()
|
v
+----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+
| | | | |
| (timeout | (answered) | (rejected) | acceptCall() | (timeout
| ~60s) | | | | ~60s)
v v v v v
ENDED CONNECTED ENDED CONNECTING ENDED
| |
| v
| CONNECTED
| |
| (hangup/error) | (hangup/error)
v v
ENDED ENDED
```
For outgoing calls, `CONNECTED` fires directly when the tunnel reports
`Connected` state -- there is no intermediate `CONNECTING` event.
For incoming calls, `CONNECTING` is set by Java when the user calls
`acceptCall()`, before the tunnel completes ICE negotiation.
Both directions have a 60-second ring timeout.
Reconnection (ICE restart):
```
CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded)
|
v
ENDED (ICE restart failed)
```
`RECONNECTING` maps from the tunnel's `Connecting` state, which is emitted
during ICE restarts (not during initial connection).
---
## CallManager.java
`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java`
Manages the call lifecycle from the Java side:
1. Spawns `signal-call-tunnel` and writes config JSON to stdin
2. Keeps stdin open as the control write channel; reads stdout for control events
3. Captures stderr for tunnel logging
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
message and includes them in `CallInfo`
5. Translates tunnel state changes into `CallInfo.State` values and fires
`callEvent` JSON-RPC notifications to connected clients
6. Defers the `accept` message for incoming calls until the tunnel reports
`Ringing` state (sending earlier causes the tunnel to drop it)
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
8. On hangup: sends hangup message, closes stdin, and destroys the process
---
## Implementation Notes
### Peer ID consistency
The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual
remote peer UUID (e.g., `senderAddress.toString()`). The tunnel rejects ICE
candidates if the peer ID doesn't match across calls, causing "Ignoring
peer-reflexive ICE candidate because the ufrag is unknown."
### sendHangup semantics
`sendHangup` from the tunnel is a request to send a hangup message via Signal
protocol. It is **not** a local state change -- local state transitions come
exclusively from `stateChange` events. For single-device clients, ignore
`AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and
`BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to
the remote peer causes it to terminate the call prematurely.
### Call ID serialization
Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when
serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In
the config JSON, `call_id` should also use unsigned representation.
### Incoming hangup filtering
When receiving hangup messages via Signal protocol, only honor `NORMAL` type
hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination
messages and should be ignored by single-device clients.
### JSON-RPC call ID types
JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use `Number.longValue()` rather than direct casting when extracting
call IDs from JSON-RPC parameters.
### Identity key format
Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw
32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). If the
33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures.

View File

@ -0,0 +1,281 @@
[
{
"name":"[Z"
},
{
"name":"com.sun.security.auth.module.UnixSystem",
"fields":[
{"name":"gid"},
{"name":"groups"},
{"name":"uid"},
{"name":"username"}
]
},
{
"name":"java.lang.Boolean",
"methods":[{"name":"getBoolean","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.Class",
"methods":[{"name":"getCanonicalName","parameterTypes":[] }]
},
{
"name":"java.lang.ClassLoader",
"methods":[
{"name":"getPlatformClassLoader","parameterTypes":[] },
{"name":"loadClass","parameterTypes":["java.lang.String"] }
]
},
{
"name":"java.lang.IllegalArgumentException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.IllegalStateException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.NoSuchMethodError"
},
{
"name":"java.lang.Throwable",
"methods":[
{"name":"getMessage","parameterTypes":[] },
{"name":"toString","parameterTypes":[] }
]
},
{
"name":"java.lang.UnsatisfiedLinkError",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.util.UUID",
"methods":[
{"name":"<init>","parameterTypes":["long","long"] },
{"name":"getLeastSignificantBits","parameterTypes":[] },
{"name":"getMostSignificantBits","parameterTypes":[] }
]
},
{
"name":"jdk.internal.loader.ClassLoaders$PlatformClassLoader"
},
{
"name":"org.asamk.signal.manager.storage.protocol.SignalProtocolStore",
"methods":[
{"name":"getIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress"] },
{"name":"getIdentityKeyPair","parameterTypes":[] },
{"name":"getLocalRegistrationId","parameterTypes":[] },
{"name":"isTrustedIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.IdentityKey","org.signal.libsignal.protocol.state.IdentityKeyStore$Direction"] },
{"name":"loadPreKey","parameterTypes":["int"] },
{"name":"loadSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID"] },
{"name":"loadSession","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress"] },
{"name":"loadSignedPreKey","parameterTypes":["int"] },
{"name":"removePreKey","parameterTypes":["int"] },
{"name":"saveIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.IdentityKey"] },
{"name":"storeSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID","org.signal.libsignal.protocol.groups.state.SenderKeyRecord"] },
{"name":"storeSession","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.state.SessionRecord"] }
]
},
{
"name":"org.asamk.signal.manager.storage.senderKeys.SenderKeyStore",
"methods":[
{"name":"loadSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID"] },
{"name":"storeSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID","org.signal.libsignal.protocol.groups.state.SenderKeyRecord"] }
]
},
{
"name":"org.graalvm.jniutils.JNIExceptionWrapperEntryPoints",
"methods":[{"name":"getClassName","parameterTypes":["java.lang.Class"] }]
},
{
"name":"org.signal.libsignal.protocol.DuplicateMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.IdentityKey",
"methods":[
{"name":"<init>","parameterTypes":["long"] },
{"name":"<init>","parameterTypes":["byte[]"] },
{"name":"serialize","parameterTypes":[] }
]
},
{
"name":"org.signal.libsignal.protocol.IdentityKeyPair",
"methods":[{"name":"serialize","parameterTypes":[] }]
},
{
"name":"org.signal.libsignal.protocol.InvalidKeyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.InvalidKeyIdException"
},
{
"name":"org.signal.libsignal.protocol.InvalidMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.NoSessionException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.SignalProtocolAddress",
"methods":[
{"name":"<init>","parameterTypes":["long"] },
{"name":"<init>","parameterTypes":["java.lang.String","int"] }
]
},
{
"name":"org.signal.libsignal.protocol.UntrustedIdentityException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.groups.state.SenderKeyRecord",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.groups.state.SenderKeyStore"
},
{
"name":"org.signal.libsignal.protocol.logging.Log",
"methods":[{"name":"log","parameterTypes":["int","java.lang.String","java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.message.PlaintextContent",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.message.PreKeySignalMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.message.SenderKeyMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.message.SignalMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.state.IdentityKeyStore"
},
{
"name":"org.signal.libsignal.protocol.state.IdentityKeyStore$Direction",
"fields":[
{"name":"RECEIVING"},
{"name":"SENDING"}
]
},
{
"name":"org.signal.libsignal.protocol.state.PreKeyRecord",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.state.PreKeyStore"
},
{
"name":"org.signal.libsignal.protocol.state.SessionRecord",
"fields":[{"name":"unsafeHandle"}],
"methods":[
{"name":"<init>","parameterTypes":["long"] },
{"name":"<init>","parameterTypes":["byte[]"] }
]
},
{
"name":"org.signal.libsignal.protocol.state.SessionStore"
},
{
"name":"org.signal.libsignal.protocol.state.SignedPreKeyRecord",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.state.SignedPreKeyStore"
},
{
"name":"org.signal.libsignal.usernames.BadNicknameCharacterException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooShortException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.zkgroup.InvalidInputException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.sqlite.BusyHandler",
"methods":[{"name":"callback","parameterTypes":["int"] }]
},
{
"name":"org.sqlite.Collation",
"methods":[{"name":"xCompare","parameterTypes":["java.lang.String","java.lang.String"] }]
},
{
"name":"org.sqlite.Function",
"fields":[
{"name":"args"},
{"name":"context"},
{"name":"value"}
],
"methods":[{"name":"xFunc","parameterTypes":[] }]
},
{
"name":"org.sqlite.Function$Aggregate",
"methods":[
{"name":"clone","parameterTypes":[] },
{"name":"xFinal","parameterTypes":[] },
{"name":"xStep","parameterTypes":[] }
]
},
{
"name":"org.sqlite.Function$Window",
"methods":[
{"name":"xInverse","parameterTypes":[] },
{"name":"xValue","parameterTypes":[] }
]
},
{
"name":"org.sqlite.ProgressHandler",
"methods":[{"name":"progress","parameterTypes":[] }]
},
{
"name":"org.sqlite.core.DB",
"methods":[
{"name":"onCommit","parameterTypes":["boolean"] },
{"name":"onUpdate","parameterTypes":["int","java.lang.String","java.lang.String","long"] },
{"name":"throwex","parameterTypes":[] },
{"name":"throwex","parameterTypes":["int"] }
]
},
{
"name":"org.sqlite.core.DB$ProgressObserver",
"methods":[{"name":"progress","parameterTypes":["int","int"] }]
},
{
"name":"org.sqlite.core.NativeDB",
"fields":[
{"name":"busyHandler"},
{"name":"commitListener"},
{"name":"pointer"},
{"name":"progressHandler"},
{"name":"updateListener"}
],
"methods":[
{"name":"stringToUtf8ByteArray","parameterTypes":["java.lang.String"] },
{"name":"throwex","parameterTypes":["java.lang.String"] }
]
}
]

View File

@ -0,0 +1,8 @@
[
{
"type":"agent-extracted",
"classes":[
]
}
]

View File

@ -0,0 +1,23 @@
[
{
"interfaces":["java.sql.Connection"]
},
{
"interfaces":["org.asamk.Signal"]
},
{
"interfaces":["org.asamk.Signal$Configuration"]
},
{
"interfaces":["org.asamk.Signal$Device"]
},
{
"interfaces":["org.asamk.Signal$Group"]
},
{
"interfaces":["org.asamk.SignalControl"]
},
{
"interfaces":["org.freedesktop.dbus.interfaces.DBus"]
}
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,244 @@
{
"resources":{
"includes":[
{
"pattern":"\\QMETA-INF/maven/org.xerial/sqlite-jdbc/pom.properties\\E"
},
{
"pattern":"\\QMETA-INF/services/ch.qos.logback.classic.spi.Configurator\\E"
},
{
"pattern":"\\QMETA-INF/services/java.sql.Driver\\E"
},
{
"pattern":"\\QMETA-INF/services/org.freedesktop.dbus.spi.transport.ITransportProvider\\E"
},
{
"pattern":"\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AG\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AI\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AS\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AT\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AU\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AZ\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BB\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BD\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BE\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BM\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BO\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BS\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CA\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CH\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CI\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CL\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CN\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CO\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CZ\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DK\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_EC\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_EE\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_ES\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_FI\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_FR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_GB\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_GR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HK\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HU\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_ID\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IL\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IN\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IT\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_JP\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_LV\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MM\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MO\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MX\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MY\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NG\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NL\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NZ\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PA\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PE\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PH\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PL\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_RO\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_RU\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SA\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SI\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SK\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_TH\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_TR\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_UA\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_UG\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_US\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_VE\\E"
},
{
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_XK\\E"
},
{
"pattern":"\\Qjni/x86_64-Linux/libjffi-1.2.so\\E"
},
{
"pattern":"\\Qlibsignal_jni.so\\E"
},
{
"pattern":"\\Qorg/asamk/signal/manager/config/ias.store\\E"
},
{
"pattern":"\\Qorg/asamk/signal/manager/config/whisper.store\\E"
},
{
"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"
},
{
"pattern":"\\Qorg/sqlite/native/Linux/x86_64/libsqlitejdbc.so\\E"
},
{
"pattern":"\\Qsqlite-jdbc.properties\\E"
},
{
"pattern":"com/google/i18n/phonenumbers/data/.*"
}
]},
"bundles":[{
"name":"net.sourceforge.argparse4j.internal.ArgumentParserImpl",
"locales":[
"",
"en",
"und"
]
}]
}

View File

@ -0,0 +1,8 @@
{
"types":[
],
"lambdaCapturingTypes":[
],
"proxies":[
]
}

View File

@ -1,29 +0,0 @@
[versions]
slf4j = "2.0.18"
coroutines = "1.10.2"
junit = "6.1.2"
micronaut-json-schema = "2.1.0"
micronaut-core = "5.1.10"
signal-service = "2.15.3_unofficial_152"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.1"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
micronaut-json-schema-annotations = { module = "io.micronaut.jsonschema:micronaut-json-schema-annotations", version.ref = "micronaut-json-schema" }
micronaut-json-schema-processor = { module = "io.micronaut.jsonschema:micronaut-json-schema-processor", version.ref = "micronaut-json-schema" }
micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-json-schema-generator", version.ref = "micronaut-json-schema" }
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
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.6.1"
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.53.2.1"
hikari = "com.zaxxer:HikariCP:7.1.0"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }

Binary file not shown.

View File

@ -1,9 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

37
gradlew vendored
View File

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015 the original authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,12 +15,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
# gradlew start up script for POSIX generated by Gradle. # Gradle start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@ -29,7 +27,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh gradlew # ksh Gradle
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -85,8 +83,7 @@ done
# This is normally unused # This is normally unused
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@ -114,6 +111,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@ -132,13 +130,10 @@ location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD=java
if ! command -v java >/dev/null 2>&1 which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
@ -146,7 +141,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045 # shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
@ -154,7 +149,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045 # shellcheck disable=SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -171,6 +166,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@ -202,15 +198,16 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command;
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# and any embedded shellness will be escaped. # shell script including quotes and variable substitutions, so put them in
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # double quotes to make sure that they get re-expanded; and
# treated as '${Hostname}' itself on the command line. # * put everything else in single quotes, so that it's not re-expanded.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

58
gradlew.bat vendored
View File

@ -13,18 +13,16 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem gradlew startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled @rem Set local scope for the variables with windows NT shell
setlocal EnableExtensions if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@ -45,13 +43,13 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1 goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@ -59,24 +57,36 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. 1>&2 echo.
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. 1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1 goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute gradlew @rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel :end
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts @rem End local scope for the variables with windows NT shell
"%COMSPEC%" /c exit %ERRORLEVEL% if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -4,37 +4,20 @@ plugins {
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_25 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_17
if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) {
toolchain {
languageVersion.set(JavaLanguageVersion.of(targetCompatibility.majorVersion))
}
}
} }
val libsignalClientPath = project.findProperty("libsignal_client_path")?.toString()
dependencies { dependencies {
if (libsignalClientPath == null) { implementation(libs.signalservice)
implementation(libs.signalnetwork)
} else {
implementation(libs.signalnetwork) {
exclude(group = "org.signal", module = "libsignal-client")
}
implementation(files(libsignalClientPath))
}
implementation(libs.jackson.databind) implementation(libs.jackson.databind)
implementation(libs.protobuf)
implementation(libs.bouncycastle) implementation(libs.bouncycastle)
implementation(libs.slf4j.api) implementation(libs.slf4j.api)
implementation(libs.sqlite) implementation(libs.sqlite)
implementation(libs.hikari) implementation(libs.hikari)
compileOnly(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter) testImplementation(libs.junit)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
} }
tasks.named<Test>("test") { tasks.named<Test>("test") {

View File

@ -1,10 +1,10 @@
package org.asamk.signal.manager.internal; package org.asamk.signal.manager;
import org.asamk.signal.manager.helper.AccountFileUpdater; import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.storage.accounts.AccountsStore; import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.signal.core.models.ServiceId.ACI; import org.whispersystems.signalservice.api.push.ACI;
public class AccountFileUpdaterImpl implements AccountFileUpdater { class AccountFileUpdaterImpl implements AccountFileUpdater {
private final AccountsStore accountsStore; private final AccountsStore accountsStore;
private final String accountPath; private final String accountPath;

View File

@ -1,4 +1,4 @@
package org.asamk.signal.manager.storage; package org.asamk.signal.manager;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.MimeUtils;
@ -22,8 +22,7 @@ public class AttachmentStore {
} }
public void storeAttachmentPreview( public void storeAttachmentPreview(
final SignalServiceAttachmentPointer pointer, final SignalServiceAttachmentPointer pointer, final AttachmentStorer storer
final AttachmentStorer storer
) throws IOException { ) throws IOException {
storeAttachment(getAttachmentPreviewFile(pointer.getRemoteId(), storeAttachment(getAttachmentPreviewFile(pointer.getRemoteId(),
pointer.getFileName(), pointer.getFileName(),
@ -31,8 +30,7 @@ public class AttachmentStore {
} }
public void storeAttachment( public void storeAttachment(
final SignalServiceAttachmentPointer pointer, final SignalServiceAttachmentPointer pointer, final AttachmentStorer storer
final AttachmentStorer storer
) throws IOException { ) throws IOException {
storeAttachment(getAttachmentFile(pointer), storer); storeAttachment(getAttachmentFile(pointer), storer);
} }
@ -44,8 +42,7 @@ public class AttachmentStore {
} }
public StreamDetails retrieveAttachment(final String id) throws IOException { public StreamDetails retrieveAttachment(final String id) throws IOException {
final var safeId = sanitizeId(id); final var attachmentFile = new File(attachmentsPath, id);
final var attachmentFile = new File(attachmentsPath, safeId);
return Utils.createStreamDetailsFromFile(attachmentFile); return Utils.createStreamDetailsFromFile(attachmentFile);
} }
@ -57,33 +54,22 @@ public class AttachmentStore {
} }
private File getAttachmentPreviewFile( private File getAttachmentPreviewFile(
SignalServiceAttachmentRemoteId attachmentId, SignalServiceAttachmentRemoteId attachmentId, Optional<String> filename, Optional<String> contentType
Optional<String> filename,
Optional<String> contentType
) { ) {
final var extension = getAttachmentExtension(filename, contentType); final var extension = getAttachmentExtension(filename, contentType);
final var safe = sanitizeId(attachmentId.toString()); return new File(attachmentsPath, attachmentId.toString() + extension + ".preview");
return new File(attachmentsPath, safe + extension + ".preview");
} }
private File getAttachmentFile( private File getAttachmentFile(
SignalServiceAttachmentRemoteId attachmentId, SignalServiceAttachmentRemoteId attachmentId, Optional<String> filename, Optional<String> contentType
Optional<String> filename,
Optional<String> contentType
) { ) {
final var extension = getAttachmentExtension(filename, contentType); final var extension = getAttachmentExtension(filename, contentType);
final var safe = sanitizeId(attachmentId.toString()); return new File(attachmentsPath, attachmentId.toString() + extension);
return new File(attachmentsPath, safe + extension);
} }
private static String sanitizeId(final String id) { private static String getAttachmentExtension(
if (id == null) { final Optional<String> filename, final Optional<String> contentType
return ""; ) {
}
return id.replaceAll("[^A-Za-z0-9_.-]", "_");
}
private static String getAttachmentExtension(final Optional<String> filename, final Optional<String> contentType) {
return filename.filter(f -> f.contains(".")) return filename.filter(f -> f.contains("."))
.map(f -> f.substring(f.lastIndexOf(".") + 1)) .map(f -> f.substring(f.lastIndexOf(".") + 1))
.or(() -> contentType.flatMap(MimeUtils::guessExtensionFromMimeType)) .or(() -> contentType.flatMap(MimeUtils::guessExtensionFromMimeType))

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.storage; package org.asamk.signal.manager;
import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;

View File

@ -1,7 +1,9 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager;
import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.ecc.ECPublicKey;
import java.net.URI; import java.net.URI;
@ -12,9 +14,9 @@ import java.util.Base64;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty; import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public record DeviceLinkUrl(String deviceIdentifier, ECPublicKey deviceKey) { public record DeviceLinkInfo(String deviceIdentifier, ECPublicKey deviceKey) {
public static DeviceLinkUrl parseDeviceLinkUri(URI linkUri) throws InvalidDeviceLinkException { public static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws InvalidDeviceLinkException {
final var rawQuery = linkUri.getRawQuery(); final var rawQuery = linkUri.getRawQuery();
if (isEmpty(rawQuery)) { if (isEmpty(rawQuery)) {
throw new RuntimeException("Invalid device link uri"); throw new RuntimeException("Invalid device link uri");
@ -36,12 +38,12 @@ public record DeviceLinkUrl(String deviceIdentifier, ECPublicKey deviceKey) {
} }
ECPublicKey deviceKey; ECPublicKey deviceKey;
try { try {
deviceKey = new ECPublicKey(publicKeyBytes); deviceKey = Curve.decodePoint(publicKeyBytes, 0);
} catch (InvalidKeyException e) { } catch (InvalidKeyException e) {
throw new InvalidDeviceLinkException("Invalid device link", e); throw new InvalidDeviceLinkException("Invalid device link", e);
} }
return new DeviceLinkUrl(deviceIdentifier, deviceKey); return new DeviceLinkInfo(deviceIdentifier, deviceKey);
} }
public URI createDeviceLinkUri() { public URI createDeviceLinkUri() {

View File

@ -0,0 +1,17 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.jobs.Job;
public class JobExecutor {
private final Context context;
public JobExecutor(final Context context) {
this.context = context;
}
public void enqueueJob(Job job) {
job.run(context);
}
}

View File

@ -1,4 +1,4 @@
package org.asamk.signal.manager.internal; package org.asamk.signal.manager;
import org.signal.libsignal.protocol.logging.SignalProtocolLogger; import org.signal.libsignal.protocol.logging.SignalProtocolLogger;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider; import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
@ -7,9 +7,9 @@ import org.slf4j.LoggerFactory;
public class LibSignalLogger implements SignalProtocolLogger { public class LibSignalLogger implements SignalProtocolLogger {
private static final Logger logger = LoggerFactory.getLogger("LibSignal"); private final static Logger logger = LoggerFactory.getLogger("LibSignal");
public static void initLogger() { static void initLogger() {
SignalProtocolLoggerProvider.setProvider(new LibSignalLogger()); SignalProtocolLoggerProvider.setProvider(new LibSignalLogger());
} }

View File

@ -1,67 +1,47 @@
package org.asamk.signal.manager; package org.asamk.signal.manager;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; 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; import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device; import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLimitExceededException;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group; 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.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.LastGroupAdminException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PendingAdminApprovalException; import org.asamk.signal.manager.api.PendingAdminApprovalException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults; 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.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack; 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.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; 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.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus; import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl; import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.api.UsernameStatus; import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.asamk.signal.manager.groups.GroupNotFoundException;
import org.signal.core.util.UuidUtil; import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
import org.slf4j.Logger; import org.asamk.signal.manager.groups.LastGroupAdminException;
import org.slf4j.LoggerFactory; import org.asamk.signal.manager.groups.NotAGroupMemberException;
import org.asamk.signal.manager.storage.recipients.Profile;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
import java.io.Closeable; import java.io.Closeable;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@ -72,32 +52,11 @@ import java.util.Set;
public interface Manager extends Closeable { public interface Manager extends Closeable {
static boolean isValidNumber(final String e164Number, final String countryCode) { static boolean isValidNumber(final String e164Number, final String countryCode) {
return PhoneNumberUtil.getInstance().isPossibleNumber(e164Number, countryCode); return PhoneNumberFormatter.isValidNumber(e164Number, countryCode);
}
static boolean isValidAci(final String aci) {
return UuidUtil.INSTANCE.isUuid(aci);
}
static boolean isSignalClientAvailable() {
final Logger logger = LoggerFactory.getLogger(Manager.class);
try {
try {
org.signal.libsignal.internal.Native.UuidCiphertext_CheckValidContents(new byte[0]);
} catch (Exception e) {
logger.trace("Expected exception when checking libsignal-client: {}", e.getMessage());
}
return true;
} catch (UnsatisfiedLinkError e) {
logger.warn("Failed to call libsignal-client: {}", e.getMessage());
return false;
}
} }
String getSelfNumber(); String getSelfNumber();
String getSelfACI();
/** /**
* This is used for checking a set of phone numbers for registration on Signal * This is used for checking a set of phone numbers for registration on Signal
* *
@ -105,20 +64,13 @@ public interface Manager extends Closeable {
* @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null. * @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null.
* @throws IOException if it's unable to get the contacts to check if they're registered * @throws IOException if it's unable to get the contacts to check if they're registered
*/ */
Map<String, UserStatus> getUserStatus(Set<String> numbers) throws IOException, RateLimitException; Map<String, UserStatus> getUserStatus(Set<String> numbers) throws IOException;
Map<String, UsernameStatus> getUsernameStatus(Set<String> usernames) throws IOException; void updateAccountAttributes(String deviceName) throws IOException;
void updateAccountAttributes(
String deviceName,
Boolean unrestrictedUnidentifiedSender,
final Boolean discoverableByNumber,
final Boolean numberSharing
) throws IOException;
Configuration getConfiguration(); Configuration getConfiguration();
void updateConfiguration(Configuration configuration) throws NotPrimaryDeviceException; void updateConfiguration(Configuration configuration) throws IOException, NotPrimaryDeviceException;
/** /**
* Update the user's profile. * Update the user's profile.
@ -126,15 +78,11 @@ public interface Manager extends Closeable {
*/ */
void updateProfile(UpdateProfile updateProfile) throws IOException; void updateProfile(UpdateProfile updateProfile) throws IOException;
String getUsername();
UsernameLinkUrl getUsernameLink();
/** /**
* Set a username for the account. * Set a username for the account.
* If the username is null, it will be deleted. * If the username is null, it will be deleted.
*/ */
void setUsername(String username) throws IOException, InvalidUsernameException; String setUsername(String username) throws IOException, InvalidUsernameException;
/** /**
* Set a username for the account. * Set a username for the account.
@ -142,61 +90,36 @@ public interface Manager extends Closeable {
*/ */
void deleteUsername() throws IOException; void deleteUsername() throws IOException;
void startChangeNumber(
String newNumber,
boolean voiceVerification,
String captcha
) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException, VerificationMethodNotAvailableException;
void finishChangeNumber(
String newNumber,
String verificationCode,
String pin
) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException, PinLockMissingException;
void unregister() throws IOException; void unregister() throws IOException;
void deleteAccount() throws IOException; void deleteAccount() throws IOException;
void submitRateLimitRecaptchaChallenge( void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException;
String challenge,
String captcha
) throws IOException, CaptchaRejectedException;
List<Device> getLinkedDevices() throws IOException; List<Device> getLinkedDevices() throws IOException;
void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException; void removeLinkedDevices(int deviceId) throws IOException;
void removeLinkedDevices(int deviceId) throws IOException, NotPrimaryDeviceException; void addDeviceLink(URI linkUri) throws IOException, InvalidDeviceLinkException;
void addDeviceLink(DeviceLinkUrl linkUri) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException, DeviceLimitExceededException;
void setRegistrationLockPin(Optional<String> pin) throws IOException, NotPrimaryDeviceException; void setRegistrationLockPin(Optional<String> pin) throws IOException, NotPrimaryDeviceException;
Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
List<Group> getGroups(); List<Group> getGroups();
List<Group> getGroups(Collection<GroupId> groupIds);
SendGroupMessageResults quitGroup( SendGroupMessageResults quitGroup(
GroupId groupId, GroupId groupId, Set<RecipientIdentifier.Single> groupAdmins
Set<RecipientIdentifier.Single> groupAdmins
) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException, UnregisteredRecipientException; ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException, UnregisteredRecipientException;
void deleteGroup(GroupId groupId) throws IOException; void deleteGroup(GroupId groupId) throws IOException;
SendGroupMessageResults terminateGroup(
GroupId groupId
) throws IOException, GroupNotFoundException, NotAGroupMemberException;
Pair<GroupId, SendGroupMessageResults> createGroup( Pair<GroupId, SendGroupMessageResults> createGroup(
String name, String name, Set<RecipientIdentifier.Single> members, String avatarFile
Set<RecipientIdentifier.Single> members,
String avatarFile
) throws IOException, AttachmentInvalidException, UnregisteredRecipientException; ) throws IOException, AttachmentInvalidException, UnregisteredRecipientException;
SendGroupMessageResults updateGroup( SendGroupMessageResults updateGroup(
final GroupId groupId, final GroupId groupId, final UpdateGroup updateGroup
final UpdateGroup updateGroup
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException, UnregisteredRecipientException; ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException, UnregisteredRecipientException;
Pair<GroupId, SendGroupMessageResults> joinGroup( Pair<GroupId, SendGroupMessageResults> joinGroup(
@ -204,42 +127,27 @@ public interface Manager extends Closeable {
) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException; ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException;
SendMessageResults sendTypingMessage( SendMessageResults sendTypingMessage(
TypingAction action, TypingAction action, Set<RecipientIdentifier> recipients
Set<RecipientIdentifier> recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException;
SendMessageResults sendReadReceipt(RecipientIdentifier.Single sender, List<Long> messageIds); SendMessageResults sendReadReceipt(
RecipientIdentifier.Single sender, List<Long> messageIds
) throws IOException;
SendMessageResults sendViewedReceipt(RecipientIdentifier.Single sender, List<Long> messageIds); SendMessageResults sendViewedReceipt(
RecipientIdentifier.Single sender, List<Long> messageIds
) throws IOException;
SendMessageResults sendMessage( SendMessageResults sendMessage(
Message message, Message message, Set<RecipientIdentifier> recipients
Set<RecipientIdentifier> recipients,
boolean notifySelf
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
SendMessageResults sendEditMessage( SendMessageResults sendEditMessage(
Message message, Message message, Set<RecipientIdentifier> recipients, long editTargetTimestamp
Set<RecipientIdentifier> recipients,
long editTargetTimestamp
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
/**
* Post a file attachment story to "My Story" or to a group.
*
* @param attachment path to the file to upload and post as a story
* @param allowsReplies whether other users are allowed to reply to this story
* @param groupId if present, post the story to this group instead of "My Story"
*/
SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
SendMessageResults sendRemoteDeleteMessage( SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp, long targetSentTimestamp, Set<RecipientIdentifier> recipients
Set<RecipientIdentifier> recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException;
SendMessageResults sendMessageReaction( SendMessageResults sendMessageReaction(
@ -248,102 +156,36 @@ public interface Manager extends Closeable {
RecipientIdentifier.Single targetAuthor, RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp, long targetSentTimestamp,
Set<RecipientIdentifier> recipients, Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException; ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendAdminDelete(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier.Group> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPinMessage(
int pinDuration,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPaymentNotificationMessage( SendMessageResults sendPaymentNotificationMessage(
byte[] receipt, byte[] receipt, String note, RecipientIdentifier.Single recipient
String note,
RecipientIdentifier.Single recipient
) throws IOException; ) throws IOException;
void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException; SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type type,
Set<RecipientIdentifier> recipientIdentifiers
);
SendMessageResults sendPollCreateMessage(
final String question,
final boolean allowMultiple,
final List<String> options,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPollVoteMessage(
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final List<Integer> optionIndexes,
final int voteCount,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPollTerminateMessage(
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
void hideRecipient(RecipientIdentifier.Single recipient);
void deleteRecipient(RecipientIdentifier.Single recipient); void deleteRecipient(RecipientIdentifier.Single recipient);
void deleteContact(RecipientIdentifier.Single recipient); void deleteContact(RecipientIdentifier.Single recipient);
void setContactName( void setContactName(
final RecipientIdentifier.Single recipient, RecipientIdentifier.Single recipient, String givenName, final String familyName
final String givenName, ) throws NotPrimaryDeviceException, IOException, UnregisteredRecipientException;
final String familyName,
final String nickGivenName,
final String nickFamilyName,
final String note
) throws UnregisteredRecipientException;
void setContactsBlocked( void setContactsBlocked(
Collection<RecipientIdentifier.Single> recipient, Collection<RecipientIdentifier.Single> recipient, boolean blocked
boolean blocked
) throws NotPrimaryDeviceException, IOException, UnregisteredRecipientException; ) throws NotPrimaryDeviceException, IOException, UnregisteredRecipientException;
void setGroupsBlocked( void setGroupsBlocked(
Collection<GroupId> groupId, Collection<GroupId> groupId, boolean blocked
boolean blocked
) throws GroupNotFoundException, IOException, NotPrimaryDeviceException; ) throws GroupNotFoundException, IOException, NotPrimaryDeviceException;
/** /**
* Change the expiration timer for a contact * Change the expiration timer for a contact
*/ */
void setExpirationTimer( void setExpirationTimer(
RecipientIdentifier.Single recipient, RecipientIdentifier.Single recipient, int messageExpirationTimer
int messageExpirationTimer
) throws IOException, UnregisteredRecipientException; ) throws IOException, UnregisteredRecipientException;
/** /**
@ -354,8 +196,6 @@ public interface Manager extends Closeable {
*/ */
StickerPackUrl uploadStickerPack(File path) throws IOException, StickerPackInvalidException; StickerPackUrl uploadStickerPack(File path) throws IOException, StickerPackInvalidException;
void installStickerPack(StickerPackUrl url) throws IOException;
List<StickerPack> getStickerPacks(); List<StickerPack> getStickerPacks();
void requestAllSyncData() throws IOException; void requestAllSyncData() throws IOException;
@ -381,16 +221,14 @@ public interface Manager extends Closeable {
/** /**
* Receive new messages from server, returns if no new message arrive in a timespan of timeout. * Receive new messages from server, returns if no new message arrive in a timespan of timeout.
*/ */
void receiveMessages( public void receiveMessages(
Optional<Duration> timeout, Optional<Duration> timeout, Optional<Integer> maxMessages, ReceiveMessageHandler handler
Optional<Integer> maxMessages,
ReceiveMessageHandler handler
) throws IOException, AlreadyReceivingException; ) throws IOException, AlreadyReceivingException;
void stopReceiveMessages();
void setReceiveConfig(ReceiveConfig receiveConfig); void setReceiveConfig(ReceiveConfig receiveConfig);
boolean hasCaughtUpWithOldMessages();
boolean isContactBlocked(RecipientIdentifier.Single recipient); boolean isContactBlocked(RecipientIdentifier.Single recipient);
void sendContacts() throws IOException; void sendContacts() throws IOException;
@ -411,13 +249,33 @@ public interface Manager extends Closeable {
List<Identity> getIdentities(RecipientIdentifier.Single recipient); List<Identity> getIdentities(RecipientIdentifier.Single recipient);
/** /**
* Trust this the identity with this fingerprint/safetyNumber * Trust this the identity with this fingerprint
* *
* @param recipient account of the identity * @param recipient account of the identity
* @param fingerprint Fingerprint
*/ */
boolean trustIdentityVerified( boolean trustIdentityVerified(
RecipientIdentifier.Single recipient, RecipientIdentifier.Single recipient, byte[] fingerprint
IdentityVerificationCode verificationCode ) throws UnregisteredRecipientException;
/**
* Trust this the identity with this safety number
*
* @param recipient account of the identity
* @param safetyNumber Safety number
*/
boolean trustIdentityVerifiedSafetyNumber(
RecipientIdentifier.Single recipient, String safetyNumber
) throws UnregisteredRecipientException;
/**
* Trust this the identity with this scannable safety number
*
* @param recipient account of the identity
* @param safetyNumber Scannable safety number
*/
boolean trustIdentityVerifiedSafetyNumber(
RecipientIdentifier.Single recipient, byte[] safetyNumber
) throws UnregisteredRecipientException; ) throws UnregisteredRecipientException;
/** /**
@ -433,59 +291,8 @@ public interface Manager extends Closeable {
InputStream retrieveAttachment(final String id) throws IOException; InputStream retrieveAttachment(final String id) throws IOException;
InputStream retrieveContactAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
InputStream retrieveProfileAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
InputStream retrieveGroupAvatar(final GroupId groupId) throws IOException;
InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException;
// --- Voice call methods ---
CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
CallInfo acceptCall(long callId) throws IOException;
void hangupCall(long callId) throws IOException;
SendMessageResult rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(
RecipientIdentifier.Single recipient,
CallOffer offer
) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(
RecipientIdentifier.Single recipient,
long callId,
byte[] answerOpaque
) throws IOException, UnregisteredRecipientException;
void sendIceUpdate(
RecipientIdentifier.Single recipient,
long callId,
List<byte[]> iceCandidates
) throws IOException, UnregisteredRecipientException;
void sendHangup(
RecipientIdentifier.Single recipient,
long callId,
MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;
List<TurnServer> getTurnServerInfo() throws IOException;
@Override @Override
void close(); void close() throws IOException;
void addCallEventListener(CallEventListener listener);
void removeCallEventListener(CallEventListener listener);
interface ReceiveMessageHandler { interface ReceiveMessageHandler {
@ -494,9 +301,4 @@ public interface Manager extends Closeable {
void handleMessage(MessageEnvelope envelope, Throwable e); void handleMessage(MessageEnvelope envelope, Throwable e);
} }
interface CallEventListener {
void handleCallEvent(CallInfo callInfo, String reason);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,8 @@
package org.asamk.signal.manager; package org.asamk.signal.manager;
import org.asamk.signal.manager.internal.LibSignalLogger;
import org.asamk.signal.manager.internal.SignalLogger;
public class ManagerLogger { public class ManagerLogger {
public static void initLogger() { public static void initLogger() {
LibSignalLogger.initLogger(); LibSignalLogger.initLogger();
SignalLogger.initLogger();
} }
} }

View File

@ -1,13 +1,5 @@
package org.asamk.signal.manager.internal; package org.asamk.signal.manager;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.SignalAccountFiles;
import org.asamk.signal.manager.api.AccountCheckException;
import org.asamk.signal.manager.api.NotRegisteredException;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -23,9 +15,9 @@ import java.util.Set;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.function.Consumer; import java.util.function.Consumer;
public class MultiAccountManagerImpl implements MultiAccountManager { class MultiAccountManagerImpl implements MultiAccountManager {
private static final Logger logger = LoggerFactory.getLogger(MultiAccountManagerImpl.class); private final static Logger logger = LoggerFactory.getLogger(MultiAccountManagerImpl.class);
private final Set<Consumer<Manager>> onManagerAddedHandlers = new HashSet<>(); private final Set<Consumer<Manager>> onManagerAddedHandlers = new HashSet<>();
private final Set<Consumer<Manager>> onManagerRemovedHandlers = new HashSet<>(); private final Set<Consumer<Manager>> onManagerRemovedHandlers = new HashSet<>();
@ -53,7 +45,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} }
} }
private void addManager(final Manager m) { void addManager(final Manager m) {
synchronized (managers) { synchronized (managers) {
if (managers.contains(m)) { if (managers.contains(m)) {
return; return;
@ -96,47 +88,9 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} }
@Override @Override
public Manager getManager(final String identifier) { public Manager getManager(final String number) {
synchronized (managers) { synchronized (managers) {
if (UuidUtil.INSTANCE.isUuid(identifier)) { return managers.stream().filter(m -> m.getSelfNumber().equals(number)).findFirst().orElse(null);
// Check if UUID corresponds to an already-loaded manager
final var existing = managers.stream()
.filter(m -> m.getSelfACI().equals(identifier))
.findFirst()
.orElse(null);
if (existing != null) {
logger.debug("Found already loaded manager for ACI: {}", identifier);
return existing;
}
// Load by ACI
try {
final var newManager = signalAccountFiles.initManagerByAci(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException e) {
logger.debug("Manager not found by ACI: {}", identifier);
} catch (IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load new manager by ACI: {}", identifier, e);
}
} else {
// Phone number check already loaded managers
var existing = managers.stream()
.filter(m -> m.getSelfNumber().equals(identifier))
.findFirst()
.orElse(null);
if (existing != null) {
return existing;
}
// Load by phone number
try {
final var newManager = signalAccountFiles.initManagerByNumber(identifier);
managers.add(newManager);
return newManager;
} catch (NotRegisteredException | IOException | IllegalArgumentException | AccountCheckException e) {
logger.warn("Failed to load manager by number: {}", identifier, e);
}
}
return null;
} }
} }
@ -164,20 +118,14 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
@Override @Override
public void close() { public void close() {
final List<Thread> closeThreads;
synchronized (managers) { synchronized (managers) {
closeThreads = new ArrayList<>(managers).stream() for (var m : new ArrayList<>(managers)) {
.map(m -> Thread.ofPlatform().name("manager-close-" + m.getSelfNumber()).start(m::close))
.toList();
}
for (final var t : closeThreads) {
try { try {
t.join(); m.close();
} catch (InterruptedException ignored) { } catch (IOException e) {
logger.warn("Cleanup failed", e);
} }
} }
synchronized (managers) {
managers.clear(); managers.clear();
} }
} }

View File

@ -1,12 +1,12 @@
package org.asamk.signal.manager.internal; package org.asamk.signal.manager;
import java.io.File; import java.io.File;
public record PathConfig( record PathConfig(
File dataPath, File attachmentsPath, File avatarsPath, File stickerPacksPath File dataPath, File attachmentsPath, File avatarsPath, File stickerPacksPath
) { ) {
public static PathConfig createDefault(final File settingsPath) { static PathConfig createDefault(final File settingsPath) {
return new PathConfig(new File(settingsPath, "data"), return new PathConfig(new File(settingsPath, "data"),
new File(settingsPath, "attachments"), new File(settingsPath, "attachments"),
new File(settingsPath, "avatars"), new File(settingsPath, "avatars"),

View File

@ -0,0 +1,241 @@
/*
Copyright (C) 2015-2022 AsamK and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.asamk.signal.manager;
import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.util.KeyHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.ConfirmCodeMessage;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.IOException;
import java.net.URI;
import java.nio.channels.OverlappingFileLockException;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import static org.asamk.signal.manager.config.ServiceConfig.getCapabilities;
class ProvisioningManagerImpl implements ProvisioningManager {
private final static Logger logger = LoggerFactory.getLogger(ProvisioningManagerImpl.class);
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final Consumer<Manager> newManagerListener;
private final AccountsStore accountsStore;
private final SignalServiceAccountManager accountManager;
private final IdentityKeyPair tempIdentityKey;
private final int registrationId;
private final int pniRegistrationId;
private final String password;
ProvisioningManagerImpl(
PathConfig pathConfig,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent,
final Consumer<Manager> newManagerListener,
final AccountsStore accountsStore
) {
this.pathConfig = pathConfig;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
this.newManagerListener = newManagerListener;
this.accountsStore = accountsStore;
tempIdentityKey = KeyUtils.generateIdentityKeyPair();
registrationId = KeyHelper.generateRegistrationId(false);
pniRegistrationId = KeyHelper.generateRegistrationId(false);
password = KeyUtils.createPassword();
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()),
ServiceConfig.GROUP_MAX_SIZE);
} catch (Throwable ignored) {
groupsV2Operations = null;
}
accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(null, null, null, password, SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY);
}
@Override
public URI getDeviceLinkUri() throws TimeoutException, IOException {
var deviceUuid = accountManager.getNewDeviceUuid();
return new DeviceLinkInfo(deviceUuid, tempIdentityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
}
@Override
public String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException {
var ret = accountManager.getNewDeviceRegistration(tempIdentityKey);
var number = ret.getNumber();
var aci = ret.getAci();
var pni = ret.getPni();
logger.info("Received link information from {}, linking in progress ...", number);
var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null) {
accountPath = accountsStore.getPathByNumber(number);
}
if (accountPath != null
&& SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)
&& !canRelinkExistingAccount(accountPath)) {
throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
}
if (accountPath == null) {
accountPath = accountsStore.addAccount(number, aci);
} else {
accountsStore.updateAccount(accountPath, number, aci);
}
var encryptedDeviceName = deviceName == null
? null
: DeviceNameUtil.encryptDeviceName(deviceName, ret.getAciIdentity().getPrivateKey());
logger.debug("Finishing new device registration");
var deviceId = accountManager.finishNewDeviceRegistration(ret.getProvisioningCode(),
new ConfirmCodeMessage(false,
true,
registrationId,
pniRegistrationId,
encryptedDeviceName,
getCapabilities(false)));
// Create new account with the synced identity
var profileKey = ret.getProfileKey() == null ? KeyUtils.createProfileKey() : ret.getProfileKey();
SignalAccount account = null;
try {
account = SignalAccount.createOrUpdateLinkedAccount(pathConfig.dataPath(),
accountPath,
number,
serviceEnvironmentConfig.getType(),
aci,
pni,
password,
encryptedDeviceName,
deviceId,
ret.getAciIdentity(),
ret.getPniIdentity(),
registrationId,
pniRegistrationId,
profileKey,
Settings.DEFAULT);
account.getConfigurationStore().setReadReceipts(ret.isReadReceipts());
ManagerImpl m = null;
try {
m = new ManagerImpl(account,
pathConfig,
new AccountFileUpdaterImpl(accountsStore, accountPath),
serviceEnvironmentConfig,
userAgent);
account = null;
logger.debug("Refreshing pre keys");
try {
m.refreshPreKeys();
} catch (Exception e) {
logger.error("Failed to refresh pre keys.", e);
}
logger.debug("Requesting sync data");
try {
m.requestAllSyncData();
} catch (Exception e) {
logger.error(
"Failed to request sync messages from linked device, data can be requested again with `sendSyncRequest`.",
e);
}
if (newManagerListener != null) {
newManagerListener.accept(m);
m = null;
}
return number;
} finally {
if (m != null) {
m.close();
}
}
} finally {
if (account != null) {
account.close();
}
}
}
private boolean canRelinkExistingAccount(final String accountPath) throws IOException {
final SignalAccount signalAccount;
try {
signalAccount = SignalAccount.load(pathConfig.dataPath(), accountPath, false, Settings.DEFAULT);
} catch (IOException e) {
logger.debug("Account in use or failed to load.", e);
return false;
} catch (OverlappingFileLockException e) {
logger.debug("Account in use.", e);
return false;
}
try (signalAccount) {
if (signalAccount.isPrimaryDevice()) {
logger.debug("Account is a primary device.");
return false;
}
if (signalAccount.isRegistered()
&& signalAccount.getServiceEnvironment() != null
&& signalAccount.getServiceEnvironment() != serviceEnvironmentConfig.getType()) {
logger.debug("Account is registered in another environment: {}.",
signalAccount.getServiceEnvironment());
return false;
}
final var m = new ManagerImpl(signalAccount,
pathConfig,
new AccountFileUpdaterImpl(accountsStore, accountPath),
serviceEnvironmentConfig,
userAgent);
try (m) {
m.checkAccountState();
} catch (AuthorizationFailedException ignored) {
return true;
}
logger.debug("Account is still successfully linked.");
return false;
}
}
}

View File

@ -3,10 +3,8 @@ package org.asamk.signal.manager;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import java.io.Closeable; import java.io.Closeable;
import java.io.IOException; import java.io.IOException;
@ -14,15 +12,12 @@ import java.io.IOException;
public interface RegistrationManager extends Closeable { public interface RegistrationManager extends Closeable {
void register( void register(
boolean voiceVerification, boolean voiceVerification, String captcha
String captcha, ) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException;
final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
void verifyAccount( void verifyAccount(
String verificationCode, String verificationCode, String pin
String pin ) throws IOException, PinLockedException, IncorrectPinException;
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
void deleteLocalAccountData() throws IOException; void deleteLocalAccountData() throws IOException;

View File

@ -0,0 +1,243 @@
/*
Copyright (C) 2015-2022 AsamK and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.asamk.signal.manager;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.IOException;
import java.util.function.Consumer;
class RegistrationManagerImpl implements RegistrationManager {
private final static Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
private SignalAccount account;
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final Consumer<Manager> newManagerListener;
private final SignalServiceAccountManager accountManager;
private final PinHelper pinHelper;
private final AccountFileUpdater accountFileUpdater;
RegistrationManagerImpl(
SignalAccount account,
PathConfig pathConfig,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent,
Consumer<Manager> newManagerListener,
AccountFileUpdater accountFileUpdater
) {
this.account = account;
this.pathConfig = pathConfig;
this.accountFileUpdater = accountFileUpdater;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
this.newManagerListener = newManagerListener;
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()),
ServiceConfig.GROUP_MAX_SIZE);
} catch (Throwable ignored) {
groupsV2Operations = null;
}
this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(
// Using empty UUID, because registering doesn't work otherwise
null, null, account.getNumber(), account.getPassword(), SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY);
final var keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(),
serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(),
serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(),
serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(),
10);
final var fallbackKeyBackupServices = serviceEnvironmentConfig.getFallbackKeyBackupConfigs()
.stream()
.map(config -> accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(),
config.getEnclaveName(),
config.getServiceId(),
config.getMrenclave(),
10))
.toList();
this.pinHelper = new PinHelper(keyBackupService, fallbackKeyBackupServices);
}
@Override
public void register(
boolean voiceVerification, String captcha
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException {
if (account.isRegistered()
&& account.getServiceEnvironment() != null
&& account.getServiceEnvironment() != serviceEnvironmentConfig.getType()) {
throw new IOException("Account is registered in another environment: " + account.getServiceEnvironment());
}
if (account.getAci() != null && attemptReactivateAccount()) {
return;
}
String sessionId = NumberVerificationUtils.handleVerificationSession(accountManager,
account.getSessionId(account.getNumber()),
id -> account.setSessionId(account.getNumber(), id),
voiceVerification,
captcha);
NumberVerificationUtils.requestVerificationCode(accountManager, sessionId, voiceVerification);
}
@Override
public void verifyAccount(
String verificationCode, String pin
) throws IOException, PinLockedException, IncorrectPinException {
var sessionId = account.getSessionId(account.getNumber());
final var result = NumberVerificationUtils.verifyNumber(sessionId,
verificationCode,
pin,
pinHelper,
this::verifyAccountWithCode);
final var response = result.first();
final var masterKey = result.second();
if (masterKey == null) {
pin = null;
}
//accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
final var aci = ACI.parseOrNull(response.getUuid());
final var pni = PNI.parseOrNull(response.getPni());
account.finishRegistration(aci, pni, masterKey, pin);
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
ManagerImpl m = null;
try {
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
account = null;
m.refreshPreKeys();
if (response.isStorageCapable()) {
m.retrieveRemoteStorage();
}
// Set an initial empty profile so user can be added to groups
try {
m.updateProfile(UpdateProfile.newBuilder().build());
} catch (NoClassDefFoundError e) {
logger.warn("Failed to set default profile: {}", e.getMessage());
}
try {
m.refreshCurrentUsername();
} catch (IOException | BaseUsernameException e) {
logger.warn("Failed to refresh current username", e);
}
if (newManagerListener != null) {
newManagerListener.accept(m);
m = null;
}
} finally {
if (m != null) {
m.close();
}
}
}
@Override
public void deleteLocalAccountData() throws IOException {
account.deleteAccountData();
accountFileUpdater.removeAccount();
account = null;
}
@Override
public boolean isRegistered() {
return account.isRegistered();
}
private boolean attemptReactivateAccount() {
try {
final var accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
account.getCredentialsProvider(),
userAgent,
null,
ServiceConfig.AUTOMATIC_NETWORK_RETRY);
accountManager.setAccountAttributes(account.getAccountAttributes(null));
account.setRegistered(true);
logger.info("Reactivated existing account, verify is not necessary.");
if (newManagerListener != null) {
final var m = new ManagerImpl(account,
pathConfig,
accountFileUpdater,
serviceEnvironmentConfig,
userAgent);
account = null;
newManagerListener.accept(m);
}
return true;
} catch (IOException e) {
logger.debug("Failed to reactivate account");
}
return false;
}
private VerifyAccountResponse verifyAccountWithCode(
final String sessionId, final String verificationCode, final String registrationLock
) throws IOException {
try {
Utils.handleResponseException(accountManager.verifyAccount(verificationCode, sessionId));
} catch (AlreadyVerifiedException e) {
// Already verified so can continue registering
}
return Utils.handleResponseException(accountManager.registerAccount(sessionId,
null,
account.getAccountAttributes(registrationLock),
true));
}
@Override
public void close() {
if (account != null) {
account.close();
account = null;
}
}
}

View File

@ -1,8 +1,8 @@
package org.asamk.signal.manager; package org.asamk.signal.manager;
import org.asamk.signal.manager.api.TrustNewIdentity; import org.asamk.signal.manager.storage.identities.TrustNewIdentity;
public record Settings(TrustNewIdentity trustNewIdentity, boolean disableMessageSendLog) { public record Settings(TrustNewIdentity trustNewIdentity, boolean disableMessageSendLog) {
public static final Settings DEFAULT = new Settings(TrustNewIdentity.ON_FIRST_USE, false); public static Settings DEFAULT = new Settings(TrustNewIdentity.ON_FIRST_USE, false);
} }

View File

@ -2,20 +2,13 @@ package org.asamk.signal.manager;
import org.asamk.signal.manager.api.AccountCheckException; import org.asamk.signal.manager.api.AccountCheckException;
import org.asamk.signal.manager.api.NotRegisteredException; import org.asamk.signal.manager.api.NotRegisteredException;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.internal.AccountFileUpdaterImpl;
import org.asamk.signal.manager.internal.ManagerImpl;
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.asamk.signal.manager.internal.PathConfig;
import org.asamk.signal.manager.internal.ProvisioningManagerImpl;
import org.asamk.signal.manager.internal.RegistrationManagerImpl;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore; import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.ServiceId.ACI; import org.signal.libsignal.protocol.util.KeyHelper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
@ -66,56 +59,29 @@ public class SignalAccountFiles {
} }
public MultiAccountManager initMultiAccountManager() throws IOException { public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> { final var managers = accountsStore.getAllAccounts().parallelStream().map(a -> {
try { try {
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null); return initManager(a.number(), a.path());
} catch (NotRegisteredException e) { } catch (NotRegisteredException | IOException | AccountCheckException e) {
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName()); logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return null; return null;
} catch (AccountCheckException | IOException e) { } catch (Throwable e) {
logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName()); logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return new Pair<Manager, Throwable>(null, e); throw e;
} }
}).filter(Objects::nonNull).toList(); }).filter(Objects::nonNull).toList();
for (final var pair : managerPairs) {
if (pair.second() instanceof IOException e) {
throw e;
}
}
final var managers = managerPairs.stream()
.filter(p -> p != null && p.first() != null)
.map(Pair::first)
.toList();
return new MultiAccountManagerImpl(managers, this); return new MultiAccountManagerImpl(managers, this);
} }
public Manager initManagerByNumber(String number) throws IOException, NotRegisteredException, AccountCheckException { public Manager initManager(String number) throws IOException, NotRegisteredException, AccountCheckException {
final var accountPath = accountsStore.getPathByNumber(number); final var accountPath = accountsStore.getPathByNumber(number);
return this.initManagerByNumber(number, accountPath); return this.initManager(number, accountPath);
} }
public Manager initManagerByAci(String aciStr) throws IOException, NotRegisteredException, AccountCheckException { private Manager initManager(
final var aci = ACI.parseOrThrow(aciStr); String number, String accountPath
final var accountPath = accountsStore.getPathByAci(aci);
return this.initManagerByAci(aci, accountPath);
}
private Manager initManagerByNumber(
String number,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException { ) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!number.equals(account.getNumber())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
}
return initManagerFromAccount(number, accountPath, account);
}
private SignalAccount loadAccount(final String accountPath) throws NotRegisteredException, IOException {
if (accountPath == null) { if (accountPath == null) {
throw new NotRegisteredException(); throw new NotRegisteredException();
} }
@ -123,27 +89,12 @@ public class SignalAccountFiles {
throw new NotRegisteredException(); throw new NotRegisteredException();
} }
return SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings); var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
} if (!number.equals(account.getNumber())) {
private Manager initManagerByAci(
ACI aci,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!aci.equals(account.getAci())) {
account.close(); account.close();
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci()); throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
} }
return initManagerFromAccount(aci.toString(), accountPath, account);
}
private ManagerImpl initManagerFromAccount(
final String identifier,
final String accountPath,
final SignalAccount account
) throws NotRegisteredException, IOException, AccountCheckException {
if (!account.isRegistered()) { if (!account.isRegistered()) {
account.close(); account.close();
throw new NotRegisteredException(); throw new NotRegisteredException();
@ -165,10 +116,10 @@ public class SignalAccountFiles {
manager.checkAccountState(); manager.checkAccountState();
} catch (DeprecatedVersionException e) { } catch (DeprecatedVersionException e) {
manager.close(); manager.close();
throw new IOException("signal-cli version is too old for the Signal-Server, please update."); throw new AccountCheckException("signal-cli version is too old for the Signal-Server, please update.");
} catch (IOException e) { } catch (IOException e) {
manager.close(); manager.close();
throw new AccountCheckException("Error while checking account " + identifier + ": " + e.getMessage(), e); throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e);
} }
if (account.getServiceEnvironment() == null) { if (account.getServiceEnvironment() == null) {
@ -196,14 +147,15 @@ public class SignalAccountFiles {
} }
public RegistrationManager initRegistrationManager( public RegistrationManager initRegistrationManager(
String number, String number, Consumer<Manager> newManagerListener
Consumer<Manager> newManagerListener
) throws IOException { ) throws IOException {
final var accountPath = accountsStore.getPathByNumber(number); final var accountPath = accountsStore.getPathByNumber(number);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) { if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath; final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
var aciIdentityKey = KeyUtils.generateIdentityKeyPair(); var aciIdentityKey = KeyUtils.generateIdentityKeyPair();
var pniIdentityKey = KeyUtils.generateIdentityKeyPair(); var pniIdentityKey = KeyUtils.generateIdentityKeyPair();
var registrationId = KeyHelper.generateRegistrationId(false);
var pniRegistrationId = KeyHelper.generateRegistrationId(false);
var profileKey = KeyUtils.createProfileKey(); var profileKey = KeyUtils.createProfileKey();
var account = SignalAccount.create(pathConfig.dataPath(), var account = SignalAccount.create(pathConfig.dataPath(),
@ -212,9 +164,10 @@ public class SignalAccountFiles {
serviceEnvironment, serviceEnvironment,
aciIdentityKey, aciIdentityKey,
pniIdentityKey, pniIdentityKey,
registrationId,
pniRegistrationId,
profileKey, profileKey,
settings); settings);
account.initDatabase();
return new RegistrationManagerImpl(account, return new RegistrationManagerImpl(account,
pathConfig, pathConfig,
@ -229,7 +182,6 @@ public class SignalAccountFiles {
account.close(); account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber()); throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
} }
account.initDatabase();
return new RegistrationManagerImpl(account, return new RegistrationManagerImpl(account,
pathConfig, pathConfig,

View File

@ -0,0 +1,244 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.api.websocket.WebSocketFactory;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import java.util.Collection;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.function.Supplier;
public class SignalDependencies {
private final Object LOCK = new Object();
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final CredentialsProvider credentialsProvider;
private final SignalServiceDataStore dataStore;
private final ExecutorService executor;
private final SignalSessionLock sessionLock;
private boolean allowStories = true;
private SignalServiceAccountManager accountManager;
private GroupsV2Api groupsV2Api;
private GroupsV2Operations groupsV2Operations;
private ClientZkOperations clientZkOperations;
private SignalWebSocket signalWebSocket;
private SignalServiceMessageReceiver messageReceiver;
private SignalServiceMessageSender messageSender;
private KeyBackupService keyBackupService;
private ProfileService profileService;
private SignalServiceCipher cipher;
SignalDependencies(
final ServiceEnvironmentConfig serviceEnvironmentConfig,
final String userAgent,
final CredentialsProvider credentialsProvider,
final SignalServiceDataStore dataStore,
final ExecutorService executor,
final SignalSessionLock sessionLock
) {
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
this.credentialsProvider = credentialsProvider;
this.dataStore = dataStore;
this.executor = executor;
this.sessionLock = sessionLock;
}
public void resetAfterAddressChange() {
this.messageSender = null;
this.cipher = null;
getSignalWebSocket().forceNewWebSockets();
}
/**
* This method needs to be called before the first websocket is created
*/
public void setAllowStories(final boolean allowStories) {
this.allowStories = allowStories;
}
public ServiceEnvironmentConfig getServiceEnvironmentConfig() {
return serviceEnvironmentConfig;
}
public SignalSessionLock getSessionLock() {
return sessionLock;
}
public SignalServiceAccountManager getAccountManager() {
return getOrCreate(() -> accountManager,
() -> accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
credentialsProvider,
userAgent,
getGroupsV2Operations(),
ServiceConfig.AUTOMATIC_NETWORK_RETRY));
}
public SignalServiceAccountManager createUnauthenticatedAccountManager(String number, String password) {
return new SignalServiceAccountManager(getServiceEnvironmentConfig().getSignalServiceConfiguration(),
null,
null,
number,
SignalServiceAddress.DEFAULT_DEVICE_ID,
password,
userAgent,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
ServiceConfig.GROUP_MAX_SIZE);
}
public GroupsV2Api getGroupsV2Api() {
return getOrCreate(() -> groupsV2Api, () -> groupsV2Api = getAccountManager().getGroupsV2Api());
}
public GroupsV2Operations getGroupsV2Operations() {
return getOrCreate(() -> groupsV2Operations,
() -> groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()),
ServiceConfig.GROUP_MAX_SIZE));
}
private ClientZkOperations getClientZkOperations() {
return getOrCreate(() -> clientZkOperations,
() -> clientZkOperations = ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
}
private ClientZkProfileOperations getClientZkProfileOperations() {
final var clientZkOperations = getClientZkOperations();
return clientZkOperations == null ? null : clientZkOperations.getProfileOperations();
}
public SignalWebSocket getSignalWebSocket() {
return getOrCreate(() -> signalWebSocket, () -> {
final var timer = new UptimeSleepTimer();
final var healthMonitor = new SignalWebSocketHealthMonitor(timer);
final var webSocketFactory = new WebSocketFactory() {
@Override
public WebSocketConnection createWebSocket() {
return new WebSocketConnection("normal",
serviceEnvironmentConfig.getSignalServiceConfiguration(),
Optional.of(credentialsProvider),
userAgent,
healthMonitor,
allowStories);
}
@Override
public WebSocketConnection createUnidentifiedWebSocket() {
return new WebSocketConnection("unidentified",
serviceEnvironmentConfig.getSignalServiceConfiguration(),
Optional.empty(),
userAgent,
healthMonitor,
allowStories);
}
};
signalWebSocket = new SignalWebSocket(webSocketFactory);
healthMonitor.monitor(signalWebSocket);
});
}
public SignalServiceMessageReceiver getMessageReceiver() {
return getOrCreate(() -> messageReceiver,
() -> messageReceiver = new SignalServiceMessageReceiver(serviceEnvironmentConfig.getSignalServiceConfiguration(),
credentialsProvider,
userAgent,
getClientZkProfileOperations(),
ServiceConfig.AUTOMATIC_NETWORK_RETRY));
}
public SignalServiceMessageSender getMessageSender() {
return getOrCreate(() -> messageSender,
() -> messageSender = new SignalServiceMessageSender(serviceEnvironmentConfig.getSignalServiceConfiguration(),
credentialsProvider,
dataStore,
sessionLock,
userAgent,
getSignalWebSocket(),
Optional.empty(),
getClientZkProfileOperations(),
executor,
ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.AUTOMATIC_NETWORK_RETRY));
}
public KeyBackupService getKeyBackupService() {
return getOrCreate(() -> keyBackupService,
() -> keyBackupService = getAccountManager().getKeyBackupService(ServiceConfig.getIasKeyStore(),
serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(),
serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(),
serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(),
10));
}
public Collection<KeyBackupService> getFallbackKeyBackupServices() {
return serviceEnvironmentConfig.getFallbackKeyBackupConfigs()
.stream()
.map(config -> getAccountManager().getKeyBackupService(ServiceConfig.getIasKeyStore(),
config.getEnclaveName(),
config.getServiceId(),
config.getMrenclave(),
10))
.toList();
}
public ProfileService getProfileService() {
return getOrCreate(() -> profileService,
() -> profileService = new ProfileService(getClientZkProfileOperations(),
getMessageReceiver(),
getSignalWebSocket()));
}
public SignalServiceCipher getCipher() {
return getOrCreate(() -> cipher, () -> {
final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.getUnidentifiedSenderTrustRoot());
final var address = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164());
final var deviceId = credentialsProvider.getDeviceId();
cipher = new SignalServiceCipher(address, deviceId, dataStore.aci(), sessionLock, certificateValidator);
});
}
private <T> T getOrCreate(Supplier<T> supplier, Callable creator) {
var value = supplier.get();
if (value != null) {
return value;
}
synchronized (LOCK) {
value = supplier.get();
if (value != null) {
return value;
}
creator.call();
return supplier.get();
}
}
private interface Callable {
void call();
}
}

View File

@ -0,0 +1,196 @@
package org.asamk.signal.manager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Monitors the health of the identified and unidentified WebSockets. If either one appears to be
* unhealthy, will trigger restarting both.
* <p>
* The monitor is also responsible for sending heartbeats/keep-alive messages to prevent
* timeouts.
*/
final class SignalWebSocketHealthMonitor implements HealthMonitor {
private final static Logger logger = LoggerFactory.getLogger(SignalWebSocketHealthMonitor.class);
private static final long KEEP_ALIVE_SEND_CADENCE = TimeUnit.SECONDS.toMillis(WebSocketConnection.KEEPALIVE_TIMEOUT_SECONDS);
private static final long MAX_TIME_SINCE_SUCCESSFUL_KEEP_ALIVE = KEEP_ALIVE_SEND_CADENCE * 3;
private SignalWebSocket signalWebSocket;
private final SleepTimer sleepTimer;
private volatile KeepAliveSender keepAliveSender;
private final HealthState identified = new HealthState();
private final HealthState unidentified = new HealthState();
public SignalWebSocketHealthMonitor(SleepTimer sleepTimer) {
this.sleepTimer = sleepTimer;
}
public void monitor(SignalWebSocket signalWebSocket) {
Preconditions.checkNotNull(signalWebSocket);
Preconditions.checkArgument(this.signalWebSocket == null, "monitor can only be called once");
this.signalWebSocket = signalWebSocket;
//noinspection ResultOfMethodCallIgnored
signalWebSocket.getWebSocketState()
.subscribeOn(Schedulers.computation())
.observeOn(Schedulers.computation())
.distinctUntilChanged()
.subscribe(s -> onStateChange(s, identified));
//noinspection ResultOfMethodCallIgnored
signalWebSocket.getUnidentifiedWebSocketState()
.subscribeOn(Schedulers.computation())
.observeOn(Schedulers.computation())
.distinctUntilChanged()
.subscribe(s -> onStateChange(s, unidentified));
}
private synchronized void onStateChange(WebSocketConnectionState connectionState, HealthState healthState) {
switch (connectionState) {
case CONNECTED -> logger.debug("WebSocket is now connected");
case AUTHENTICATION_FAILED -> logger.debug("WebSocket authentication failed");
case FAILED -> logger.debug("WebSocket connection failed");
}
healthState.needsKeepAlive = connectionState == WebSocketConnectionState.CONNECTED;
if (keepAliveSender == null && isKeepAliveNecessary()) {
keepAliveSender = new KeepAliveSender();
keepAliveSender.start();
} else if (keepAliveSender != null && !isKeepAliveNecessary()) {
keepAliveSender.shutdown();
keepAliveSender = null;
}
}
@Override
public void onKeepAliveResponse(long sentTimestamp, boolean isIdentifiedWebSocket) {
if (isIdentifiedWebSocket) {
identified.lastKeepAliveReceived = System.currentTimeMillis();
} else {
unidentified.lastKeepAliveReceived = System.currentTimeMillis();
}
}
@Override
public void onMessageError(int status, boolean isIdentifiedWebSocket) {
if (status == 409) {
HealthState healthState = (isIdentifiedWebSocket ? identified : unidentified);
if (healthState.mismatchErrorTracker.addSample(System.currentTimeMillis())) {
logger.warn("Received too many mismatch device errors, forcing new websockets.");
signalWebSocket.forceNewWebSockets();
signalWebSocket.connect();
}
}
}
private boolean isKeepAliveNecessary() {
return identified.needsKeepAlive || unidentified.needsKeepAlive;
}
private static class HealthState {
private final HttpErrorTracker mismatchErrorTracker = new HttpErrorTracker(5, TimeUnit.MINUTES.toMillis(1));
private volatile boolean needsKeepAlive;
private volatile long lastKeepAliveReceived;
}
/**
* Sends periodic heartbeats/keep-alives over both WebSockets to prevent connection timeouts. If
* either WebSocket fails 3 times to get a return heartbeat both are forced to be recreated.
*/
private class KeepAliveSender extends Thread {
private volatile boolean shouldKeepRunning = true;
public void run() {
identified.lastKeepAliveReceived = System.currentTimeMillis();
unidentified.lastKeepAliveReceived = System.currentTimeMillis();
while (shouldKeepRunning && isKeepAliveNecessary()) {
try {
sleepTimer.sleep(KEEP_ALIVE_SEND_CADENCE);
if (shouldKeepRunning && isKeepAliveNecessary()) {
long keepAliveRequiredSinceTime = System.currentTimeMillis()
- MAX_TIME_SINCE_SUCCESSFUL_KEEP_ALIVE;
if (identified.lastKeepAliveReceived < keepAliveRequiredSinceTime
|| unidentified.lastKeepAliveReceived < keepAliveRequiredSinceTime) {
logger.warn("Missed keep alives, identified last: "
+ identified.lastKeepAliveReceived
+ " unidentified last: "
+ unidentified.lastKeepAliveReceived
+ " needed by: "
+ keepAliveRequiredSinceTime);
signalWebSocket.forceNewWebSockets();
signalWebSocket.connect();
} else {
signalWebSocket.sendKeepAlive();
}
}
} catch (Throwable e) {
logger.warn("Error occured in KeepAliveSender, ignoring ...", e);
}
}
}
public void shutdown() {
shouldKeepRunning = false;
}
}
private final static class HttpErrorTracker {
private final long[] timestamps;
private final long errorTimeRange;
public HttpErrorTracker(int samples, long errorTimeRange) {
this.timestamps = new long[samples];
this.errorTimeRange = errorTimeRange;
}
public synchronized boolean addSample(long now) {
long errorsMustBeAfter = now - errorTimeRange;
int count = 1;
int minIndex = 0;
for (int i = 0; i < timestamps.length; i++) {
if (timestamps[i] < errorsMustBeAfter) {
timestamps[i] = 0;
} else if (timestamps[i] != 0) {
count++;
}
if (timestamps[i] < timestamps[minIndex]) {
minIndex = i;
}
}
timestamps[minIndex] = now;
if (count >= timestamps.length) {
Arrays.fill(timestamps, 0);
return true;
}
return false;
}
}
}

View File

@ -2,25 +2,25 @@ package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.core.models.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId;
public class RenewSessionAction implements HandleAction { public class RenewSessionAction implements HandleAction {
private final RecipientId recipientId; private final RecipientId recipientId;
private final ServiceId serviceId; private final ServiceId serviceId;
private final ServiceId accountId;
public RenewSessionAction(final RecipientId recipientId, final ServiceId serviceId, final ServiceId accountId) { public RenewSessionAction(final RecipientId recipientId, final ServiceId serviceId) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.serviceId = serviceId; this.serviceId = serviceId;
this.accountId = accountId;
} }
@Override @Override
public void execute(Context context) throws Throwable { public void execute(Context context) throws Throwable {
context.getAccount().getAccountData(accountId).getSessionStore().archiveSessions(serviceId); context.getAccount().getAciSessionStore().archiveSessions(serviceId);
if (!recipientId.equals(context.getAccount().getSelfRecipientId())) {
context.getSendHelper().sendNullMessage(recipientId); context.getSendHelper().sendNullMessage(recipientId);
} }
}
@Override @Override
public boolean equals(final Object o) { public boolean equals(final Object o) {

View File

@ -13,9 +13,7 @@ public class ResendMessageAction implements HandleAction {
private final MessageSendLogEntry messageSendLogEntry; private final MessageSendLogEntry messageSendLogEntry;
public ResendMessageAction( public ResendMessageAction(
final RecipientId recipientId, final RecipientId recipientId, final long timestamp, final MessageSendLogEntry messageSendLogEntry
final long timestamp,
final MessageSendLogEntry messageSendLogEntry
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.timestamp = timestamp; this.timestamp = timestamp;

View File

@ -1,20 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public class RetrieveDeviceNameAction implements HandleAction {
private static final RetrieveDeviceNameAction INSTANCE = new RetrieveDeviceNameAction();
public static RetrieveDeviceNameAction create() {
return INSTANCE;
}
private RetrieveDeviceNameAction() {
}
@Override
public void execute(Context context) throws Throwable {
context.getAccountHelper().refreshDeviceName();
}
}

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public class RetrieveStorageDataAction implements HandleAction {
private static final RetrieveStorageDataAction INSTANCE = new RetrieveStorageDataAction();
private RetrieveStorageDataAction() {
}
public static RetrieveStorageDataAction create() {
return INSTANCE;
}
@Override
public void execute(Context context) throws Throwable {
context.getStorageHelper().readDataFromStorage();
}
}

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.actions; package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupIdV1; import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.actions; package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupIdV1; import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public class SendPniIdentityKeyAction implements HandleAction {
private static final SendPniIdentityKeyAction INSTANCE = new SendPniIdentityKeyAction();
private SendPniIdentityKeyAction() {
}
public static SendPniIdentityKeyAction create() {
return INSTANCE;
}
@Override
public void execute(Context context) throws Throwable {
context.getSyncHelper().sendPniIdentity();
}
}

View File

@ -15,9 +15,7 @@ public class SendReceiptAction implements HandleAction {
private final List<Long> timestamps = new ArrayList<>(); private final List<Long> timestamps = new ArrayList<>();
public SendReceiptAction( public SendReceiptAction(
final RecipientId recipientId, final RecipientId recipientId, final SignalServiceReceiptMessage.Type type, final long timestamp
final SignalServiceReceiptMessage.Type type,
final long timestamp
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.type = type; this.type = type;

View File

@ -1,34 +1,40 @@
package org.asamk.signal.manager.actions; package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.metadata.ProtocolException; import org.signal.libsignal.metadata.ProtocolException;
import org.signal.libsignal.protocol.message.CiphertextMessage; import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage; import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.Envelope; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.util.Optional; import java.util.Optional;
public class SendRetryMessageRequestAction implements HandleAction { public class SendRetryMessageRequestAction implements HandleAction {
private final RecipientId recipientId; private final RecipientId recipientId;
private final ServiceId serviceId;
private final ProtocolException protocolException; private final ProtocolException protocolException;
private final SignalServiceEnvelope envelope; private final SignalServiceEnvelope envelope;
public SendRetryMessageRequestAction( public SendRetryMessageRequestAction(
final RecipientId recipientId, final RecipientId recipientId,
final ServiceId serviceId,
final ProtocolException protocolException, final ProtocolException protocolException,
final SignalServiceEnvelope envelope final SignalServiceEnvelope envelope
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.serviceId = serviceId;
this.protocolException = protocolException; this.protocolException = protocolException;
this.envelope = envelope; this.envelope = envelope;
} }
@Override @Override
public void execute(Context context) throws Throwable { public void execute(Context context) throws Throwable {
context.getAccount().getAciSessionStore().archiveSessions(serviceId);
int senderDevice = protocolException.getSenderDevice(); int senderDevice = protocolException.getSenderDevice();
Optional<GroupId> groupId = protocolException.getGroupId().isPresent() ? Optional.of(GroupId.unknownVersion( Optional<GroupId> groupId = protocolException.getGroupId().isPresent() ? Optional.of(GroupId.unknownVersion(
protocolException.getGroupId().get())) : Optional.empty(); protocolException.getGroupId().get())) : Optional.empty();
@ -41,9 +47,7 @@ public class SendRetryMessageRequestAction implements HandleAction {
envelopeType = messageContent.getType(); envelopeType = messageContent.getType();
} else { } else {
originalContent = envelope.getContent(); originalContent = envelope.getContent();
envelopeType = envelope.getType() == null envelopeType = envelopeTypeToCiphertextMessageType(envelope.getType());
? CiphertextMessage.WHISPER_TYPE
: envelopeTypeToCiphertextMessageType(envelope.getType());
} }
DecryptionErrorMessage decryptionErrorMessage = DecryptionErrorMessage.forOriginalMessage(originalContent, DecryptionErrorMessage decryptionErrorMessage = DecryptionErrorMessage.forOriginalMessage(originalContent,
@ -55,14 +59,10 @@ public class SendRetryMessageRequestAction implements HandleAction {
} }
private static int envelopeTypeToCiphertextMessageType(int envelopeType) { private static int envelopeTypeToCiphertextMessageType(int envelopeType) {
final var type = Envelope.Type.fromValue(envelopeType); return switch (envelopeType) {
if (type == null) { case SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE -> CiphertextMessage.PREKEY_TYPE;
return CiphertextMessage.WHISPER_TYPE; case SignalServiceProtos.Envelope.Type.UNIDENTIFIED_SENDER_VALUE -> CiphertextMessage.SENDERKEY_TYPE;
} case SignalServiceProtos.Envelope.Type.PLAINTEXT_CONTENT_VALUE -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE;
return switch (type) {
case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE;
case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE;
case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE;
default -> CiphertextMessage.WHISPER_TYPE; default -> CiphertextMessage.WHISPER_TYPE;
}; };
} }

View File

@ -1,21 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.jobs.SyncStorageJob;
public class SyncStorageDataAction implements HandleAction {
private static final SyncStorageDataAction INSTANCE = new SyncStorageDataAction();
private SyncStorageDataAction() {
}
public static SyncStorageDataAction create() {
return INSTANCE;
}
@Override
public void execute(Context context) throws Throwable {
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
}

View File

@ -1,21 +0,0 @@
package org.asamk.signal.manager.api;
public record CallInfo(
long callId,
State state,
RecipientAddress recipient,
String inputDeviceName,
String outputDeviceName,
boolean isOutgoing
) {
public enum State {
IDLE,
RINGING_INCOMING,
RINGING_OUTGOING,
CONNECTING,
CONNECTED,
RECONNECTING,
ENDED
}
}

View File

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

View File

@ -1,16 +0,0 @@
package org.asamk.signal.manager.api;
public class CaptchaRejectedException extends Exception {
public CaptchaRejectedException() {
super("Captcha rejected");
}
public CaptchaRejectedException(final String message) {
super(message);
}
public CaptchaRejectedException(final String message, final Throwable cause) {
super(message, cause);
}
}

View File

@ -2,11 +2,11 @@ package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception { public class CaptchaRequiredException extends Exception {
private long nextVerificationAttemptMilliseconds; private long nextAttemptTimestamp;
public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) { public CaptchaRequiredException(final long nextAttemptTimestamp) {
super("Captcha required"); super("Captcha required");
this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds; this.nextAttemptTimestamp = nextAttemptTimestamp;
} }
public CaptchaRequiredException(final String message) { public CaptchaRequiredException(final String message) {
@ -17,7 +17,7 @@ public class CaptchaRequiredException extends Exception {
super(message, cause); super(message, cause);
} }
public long getNextVerificationAttemptMilliseconds() { public long getNextAttemptTimestamp() {
return nextVerificationAttemptMilliseconds; return nextAttemptTimestamp;
} }
} }

View File

@ -1,222 +0,0 @@
package org.asamk.signal.manager.api;
import org.whispersystems.signalservice.internal.util.Util;
public record Contact(
String givenName,
String familyName,
String nickName,
String nickNameGivenName,
String nickNameFamilyName,
String note,
String color,
int messageExpirationTime,
int messageExpirationTimeVersion,
long muteUntil,
boolean hideStory,
boolean isBlocked,
long blockedAt,
boolean isArchived,
boolean isProfileSharingEnabled,
boolean isHidden,
Long unregisteredTimestamp
) {
private Contact(final Builder builder) {
this(builder.givenName,
builder.familyName,
builder.nickName,
builder.nickNameGivenName,
builder.nickNameFamilyName,
builder.note,
builder.color,
builder.messageExpirationTime,
builder.messageExpirationTimeVersion,
builder.muteUntil,
builder.hideStory,
builder.isBlocked,
builder.blockedAt,
builder.isArchived,
builder.isProfileSharingEnabled,
builder.isHidden,
builder.unregisteredTimestamp);
}
public static Builder newBuilder() {
return new Builder();
}
public static Builder newBuilder(final Contact copy) {
Builder builder = new Builder();
builder.givenName = copy.givenName();
builder.familyName = copy.familyName();
builder.nickName = copy.nickName();
builder.nickNameGivenName = copy.nickNameGivenName();
builder.nickNameFamilyName = copy.nickNameFamilyName();
builder.note = copy.note();
builder.color = copy.color();
builder.messageExpirationTime = copy.messageExpirationTime();
builder.messageExpirationTimeVersion = copy.messageExpirationTimeVersion();
builder.muteUntil = copy.muteUntil();
builder.hideStory = copy.hideStory();
builder.isBlocked = copy.isBlocked();
builder.blockedAt = copy.blockedAt();
builder.isArchived = copy.isArchived();
builder.isProfileSharingEnabled = copy.isProfileSharingEnabled();
builder.isHidden = copy.isHidden();
builder.unregisteredTimestamp = copy.unregisteredTimestamp();
return builder;
}
public String getName() {
final var noGivenName = Util.isEmpty(givenName);
final var noFamilyName = Util.isEmpty(familyName);
if (noGivenName && noFamilyName) {
return "";
} else if (noGivenName) {
return familyName;
} else if (noFamilyName) {
return givenName;
}
return givenName + " " + familyName;
}
public String getDisplayNickname() {
final var noNickGivenName = Util.isEmpty(nickNameGivenName);
final var noNickFamilyName = Util.isEmpty(nickNameFamilyName);
if (noNickGivenName && noNickFamilyName) {
return null;
} else if (noNickGivenName) {
return nickNameFamilyName;
} else if (noNickFamilyName) {
return nickNameGivenName;
}
return nickNameGivenName + " " + nickNameFamilyName;
}
public static final class Builder {
private String givenName;
private String familyName;
private String nickName;
private String nickNameGivenName;
private String nickNameFamilyName;
private String note;
private String color;
private int messageExpirationTime;
private int messageExpirationTimeVersion = 1;
private long muteUntil;
private boolean hideStory;
private boolean isBlocked;
private long blockedAt;
private boolean isArchived;
private boolean isProfileSharingEnabled;
private boolean isHidden;
private Long unregisteredTimestamp;
private Builder() {
}
public static Builder newBuilder() {
return new Builder();
}
public Builder withGivenName(final String val) {
givenName = val;
return this;
}
public Builder withFamilyName(final String val) {
familyName = val;
return this;
}
public Builder withNickName(final String val) {
nickName = val;
return this;
}
public Builder withNickNameGivenName(final String val) {
nickNameGivenName = val;
return this;
}
public Builder withNickNameFamilyName(final String val) {
nickNameFamilyName = val;
return this;
}
public Builder withNote(final String val) {
note = val;
return this;
}
public Builder withColor(final String val) {
color = val;
return this;
}
public Builder withMessageExpirationTime(final int val) {
messageExpirationTime = val;
return this;
}
public Builder withMessageExpirationTimeVersion(final int val) {
messageExpirationTimeVersion = val;
return this;
}
public Builder withMuteUntil(final long val) {
muteUntil = val;
return this;
}
public Builder withHideStory(final boolean val) {
hideStory = val;
return this;
}
public Builder withIsBlocked(final boolean val) {
if (val && !isBlocked) {
blockedAt = System.currentTimeMillis();
} else if (!val) {
blockedAt = 0;
}
isBlocked = val;
return this;
}
public Builder withBlockedAt(final long val) {
blockedAt = val;
return this;
}
public Builder withIsArchived(final boolean val) {
isArchived = val;
return this;
}
public Builder withIsProfileSharingEnabled(final boolean val) {
isProfileSharingEnabled = val;
return this;
}
public Builder withIsHidden(final boolean val) {
isHidden = val;
return this;
}
public Builder withUnregisteredTimestamp(final Long val) {
unregisteredTimestamp = val;
return this;
}
public Contact build() {
return new Contact(this);
}
}
}

View File

@ -1,12 +0,0 @@
package org.asamk.signal.manager.api;
public class DeviceLimitExceededException extends Exception {
public DeviceLimitExceededException(final String message) {
super(message);
}
public DeviceLimitExceededException(final String message, final Throwable cause) {
super(message, cause);
}
}

View File

@ -1,5 +1,8 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.asamk.signal.manager.groups.GroupPermission;
import org.asamk.signal.manager.helper.RecipientAddressResolver; import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupInfo; import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -12,9 +15,10 @@ public record Group(
String title, String title,
String description, String description,
GroupInviteLinkUrl groupInviteLinkUrl, GroupInviteLinkUrl groupInviteLinkUrl,
Set<GroupMember> members, Set<RecipientAddress> members,
Set<RecipientAddress> pendingMembers, Set<RecipientAddress> pendingMembers,
Set<RecipientAddress> requestingMembers, Set<RecipientAddress> requestingMembers,
Set<RecipientAddress> adminMembers,
Set<RecipientAddress> bannedMembers, Set<RecipientAddress> bannedMembers,
boolean isBlocked, boolean isBlocked,
int messageExpirationTimer, int messageExpirationTimer,
@ -22,14 +26,11 @@ public record Group(
GroupPermission permissionEditDetails, GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage, GroupPermission permissionSendMessage,
boolean isMember, boolean isMember,
boolean isAdmin, boolean isAdmin
boolean isTerminated
) { ) {
public static Group from( public static Group from(
final GroupInfo groupInfo, final GroupInfo groupInfo, final RecipientAddressResolver recipientStore, final RecipientId selfRecipientId
final RecipientAddressResolver recipientStore,
final RecipientId selfRecipientId
) { ) {
return new Group(groupInfo.getGroupId(), return new Group(groupInfo.getGroupId(),
groupInfo.getTitle(), groupInfo.getTitle(),
@ -37,7 +38,8 @@ public record Group(
groupInfo.getGroupInviteLink(), groupInfo.getGroupInviteLink(),
groupInfo.getMembers() groupInfo.getMembers()
.stream() .stream()
.map(m -> org.asamk.signal.manager.api.GroupMember.from(m, recipientStore)) .map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getPendingMembers() groupInfo.getPendingMembers()
.stream() .stream()
@ -49,6 +51,11 @@ public record Group(
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress) .map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getAdminMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getBannedMembers() groupInfo.getBannedMembers()
.stream() .stream()
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)
@ -60,7 +67,6 @@ public record Group(
groupInfo.getPermissionEditDetails(), groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(), groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId), groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId), groupInfo.isAdmin(selfRecipientId));
groupInfo.isTerminated());
} }
} }

View File

@ -1,14 +0,0 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupMemberInfo;
public record GroupMember(
RecipientAddress recipientAddress, boolean isAdmin, String labelEmoji, String label
) {
public static GroupMember from(final GroupMemberInfo memberInfo, final RecipientAddressResolver recipientStore) {
return new GroupMember(recipientStore.resolveRecipientAddress(memberInfo.getRecipientId())
.toApiRecipientAddress(), memberInfo.isAdmin(), memberInfo.labelEmoji(), memberInfo.labelString());
}
}

View File

@ -1,10 +1,17 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.signal.libsignal.protocol.IdentityKey;
public record Identity( public record Identity(
RecipientAddress recipient, RecipientAddress recipient,
byte[] fingerprint, IdentityKey identityKey,
String safetyNumber, String safetyNumber,
byte[] scannableSafetyNumber, byte[] scannableSafetyNumber,
TrustLevel trustLevel, TrustLevel trustLevel,
long dateAddedTimestamp long dateAddedTimestamp
) {} ) {
public byte[] getFingerprint() {
return identityKey.getPublicKey().serialize();
}
}

View File

@ -1,28 +0,0 @@
package org.asamk.signal.manager.api;
import org.signal.libsignal.protocol.util.Hex;
import java.util.Base64;
import java.util.Locale;
public sealed interface IdentityVerificationCode {
record Fingerprint(byte[] fingerprint) implements IdentityVerificationCode {}
record SafetyNumber(String safetyNumber) implements IdentityVerificationCode {}
record ScannableSafetyNumber(byte[] safetyNumber) implements IdentityVerificationCode {}
static IdentityVerificationCode parse(String code) throws Exception {
code = code.replaceAll(" ", "");
if (code.length() == 66) {
final var fingerprintBytes = Hex.fromStringCondensed(code.toLowerCase(Locale.ROOT));
return new Fingerprint(fingerprintBytes);
} else if (code.length() == 60) {
return new SafetyNumber(code);
} else {
final var scannableSafetyNumber = Base64.getDecoder().decode(code);
return new ScannableSafetyNumber(scannableSafetyNumber);
}
}
}

View File

@ -2,10 +2,6 @@ package org.asamk.signal.manager.api;
public class InvalidNumberException extends Exception { public class InvalidNumberException extends Exception {
public InvalidNumberException(String message) {
super(message);
}
InvalidNumberException(String message, Throwable e) { InvalidNumberException(String message, Throwable e) {
super(message, e); super(message, e);
} }

View File

@ -6,30 +6,16 @@ import java.util.Optional;
public record Message( public record Message(
String messageText, String messageText,
List<String> attachments, List<String> attachments,
boolean viewOnce,
boolean voiceNote,
List<Mention> mentions, List<Mention> mentions,
Optional<Quote> quote, Optional<Quote> quote,
Optional<Sticker> sticker, Optional<Sticker> sticker,
List<Preview> previews, List<Preview> previews,
Optional<StoryReply> storyReply, Optional<StoryReply> storyReply
List<TextStyle> textStyles,
boolean urgent
) { ) {
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {} public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
public record Quote( public record Quote(long timestamp, RecipientIdentifier.Single author, String message, List<Mention> mentions) {}
long timestamp,
RecipientIdentifier.Single author,
String message,
List<Mention> mentions,
List<TextStyle> textStyles,
List<Attachment> attachments
) {
public record Attachment(String contentType, String filename, String preview) {}
}
public record Sticker(byte[] packId, int stickerId) {} public record Sticker(byte[] packId, int stickerId) {}

View File

@ -1,9 +1,9 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver; import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.libsignal.metadata.ProtocolException; import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -35,13 +35,13 @@ import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMes
import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange;
public record MessageEnvelope( public record MessageEnvelope(
Optional<RecipientAddress> sourceAddress, Optional<RecipientAddress> sourceAddress,
int sourceDevice, int sourceDevice,
@ -116,35 +116,17 @@ public record MessageEnvelope(
Optional<Long> remoteDeleteId, Optional<Long> remoteDeleteId,
Optional<Sticker> sticker, Optional<Sticker> sticker,
List<SharedContact> sharedContacts, List<SharedContact> sharedContacts,
Optional<PollCreate> pollCreate,
Optional<PollVote> pollVote,
Optional<PollTerminate> pollTerminate,
List<Mention> mentions, List<Mention> mentions,
List<Preview> previews, List<Preview> previews,
List<TextStyle> textStyles, List<TextStyle> textStyles
Optional<PinMessage> pinMessage,
Optional<UnpinMessage> unpinMessage,
Optional<AdminDelete> adminDelete
) { ) {
static Data from( static Data from(
final SignalServiceDataMessage dataMessage, final SignalServiceDataMessage dataMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
) { ) {
var body = dataMessage.getBody();
if (dataMessage.getAttachments().isPresent()) {
for (final var attachment : dataMessage.getAttachments().get()) {
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
final var longBody = longTexts.get(attachment.asPointer().getRemoteId().toString());
if (longBody != null) {
body = Optional.of(longBody);
}
}
}
}
return new Data(dataMessage.getTimestamp(), return new Data(dataMessage.getTimestamp(),
dataMessage.getGroupContext().map(GroupContext::from), dataMessage.getGroupContext().map(GroupContext::from),
dataMessage.getStoryContext() dataMessage.getStoryContext()
@ -152,17 +134,15 @@ public record MessageEnvelope(
recipientResolver, recipientResolver,
addressResolver)), addressResolver)),
dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from), dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from),
body, dataMessage.getBody(),
dataMessage.getExpiresInSeconds(), dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(), dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(), dataMessage.isViewOnce(),
false, dataMessage.isEndSession(),
dataMessage.isProfileKeyUpdate(), dataMessage.isProfileKeyUpdate(),
dataMessage.getProfileKey().isPresent(), dataMessage.getProfileKey().isPresent(),
dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)), dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)),
dataMessage.getQuote() dataMessage.getQuote().map(q -> Quote.from(q, recipientResolver, addressResolver, fileProvider)),
.filter(q -> q.getAuthor() != null && q.getAuthor().isValid())
.map(q -> Quote.from(q, recipientResolver, addressResolver, fileProvider)),
dataMessage.getPayment().map(p -> p.getPaymentNotification().isPresent() ? Payment.from(p) : null), dataMessage.getPayment().map(p -> p.getPaymentNotification().isPresent() ? Payment.from(p) : null),
dataMessage.getAttachments() dataMessage.getAttachments()
.map(a -> a.stream().map(as -> Attachment.from(as, fileProvider)).toList()) .map(a -> a.stream().map(as -> Attachment.from(as, fileProvider)).toList())
@ -174,9 +154,6 @@ public record MessageEnvelope(
.map(sharedContact -> SharedContact.from(sharedContact, fileProvider)) .map(sharedContact -> SharedContact.from(sharedContact, fileProvider))
.toList()) .toList())
.orElse(List.of()), .orElse(List.of()),
dataMessage.getPollCreate().map(PollCreate::from),
dataMessage.getPollVote().map(p -> PollVote.from(p, recipientResolver, addressResolver)),
dataMessage.getPollTerminate().map(PollTerminate::from),
dataMessage.getMentions() dataMessage.getMentions()
.map(a -> a.stream().map(m -> Mention.from(m, recipientResolver, addressResolver)).toList()) .map(a -> a.stream().map(m -> Mention.from(m, recipientResolver, addressResolver)).toList())
.orElse(List.of()), .orElse(List.of()),
@ -184,11 +161,8 @@ public record MessageEnvelope(
.map(a -> a.stream().map(preview -> Preview.from(preview, fileProvider)).toList()) .map(a -> a.stream().map(preview -> Preview.from(preview, fileProvider)).toList())
.orElse(List.of()), .orElse(List.of()),
dataMessage.getBodyRanges() dataMessage.getBodyRanges()
.map(a -> a.stream().filter(r -> r.style != null).map(TextStyle::from).toList()) .map(a -> a.stream().filter(BodyRange::hasStyle).map(TextStyle::from).toList())
.orElse(List.of()), .orElse(List.of()));
dataMessage.getPinnedMessage().map(p -> PinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getUnpinnedMessage().map(p -> UnpinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getAdminDelete().map(p -> AdminDelete.from(p, recipientResolver, addressResolver)));
} }
public record GroupContext(GroupId groupId, boolean isGroupUpdate, int revision) { public record GroupContext(GroupId groupId, boolean isGroupUpdate, int revision) {
@ -263,7 +237,7 @@ public record MessageEnvelope(
return new Quote(quote.getId(), return new Quote(quote.getId(),
addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(quote.getAuthor())) addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(quote.getAuthor()))
.toApiRecipientAddress(), .toApiRecipientAddress(),
Optional.of(quote.getText()), Optional.ofNullable(quote.getText()),
quote.getMentions() == null quote.getMentions() == null
? List.of() ? List.of()
: quote.getMentions() : quote.getMentions()
@ -277,7 +251,7 @@ public record MessageEnvelope(
? List.of() ? List.of()
: quote.getBodyRanges() : quote.getBodyRanges()
.stream() .stream()
.filter(r -> r.style != null) .filter(BodyRange::hasStyle)
.map(TextStyle::from) .map(TextStyle::from)
.toList()); .toList());
} }
@ -320,9 +294,9 @@ public record MessageEnvelope(
boolean isBorderless boolean isBorderless
) { ) {
static Attachment from(SignalServiceAttachment signalAttachment, AttachmentFileProvider fileProvider) { static Attachment from(SignalServiceAttachment attachment, AttachmentFileProvider fileProvider) {
if (signalAttachment.isPointer()) { if (attachment.isPointer()) {
final var a = signalAttachment.asPointer(); final var a = attachment.asPointer();
final var attachmentFile = fileProvider.getFile(a); final var attachmentFile = fileProvider.getFile(a);
return new Attachment(Optional.of(attachmentFile.getName()), return new Attachment(Optional.of(attachmentFile.getName()),
Optional.of(attachmentFile), Optional.of(attachmentFile),
@ -339,9 +313,8 @@ public record MessageEnvelope(
a.isGif(), a.isGif(),
a.isBorderless()); a.isBorderless());
} else { } else {
Attachment attachment = null; final var a = attachment.asStream();
try (final var a = signalAttachment.asStream()) { return new Attachment(Optional.empty(),
attachment = new Attachment(Optional.empty(),
Optional.empty(), Optional.empty(),
a.getFileName(), a.getFileName(),
a.getContentType(), a.getContentType(),
@ -355,16 +328,11 @@ public record MessageEnvelope(
a.getVoiceNote(), a.getVoiceNote(),
a.isGif(), a.isGif(),
a.isBorderless()); a.isBorderless());
return attachment;
} catch (IOException e) {
return attachment;
}
} }
} }
static Attachment from( static Attachment from(
SignalServiceDataMessage.Quote.QuotedAttachment a, SignalServiceDataMessage.Quote.QuotedAttachment a, final AttachmentFileProvider fileProvider
final AttachmentFileProvider fileProvider
) { ) {
return new Attachment(Optional.empty(), return new Attachment(Optional.empty(),
Optional.empty(), Optional.empty(),
@ -416,7 +384,7 @@ public record MessageEnvelope(
} }
public record Name( public record Name(
Optional<String> nickname, Optional<String> display,
Optional<String> given, Optional<String> given,
Optional<String> family, Optional<String> family,
Optional<String> prefix, Optional<String> prefix,
@ -425,7 +393,7 @@ public record MessageEnvelope(
) { ) {
static Name from(org.whispersystems.signalservice.api.messages.shared.SharedContact.Name name) { static Name from(org.whispersystems.signalservice.api.messages.shared.SharedContact.Name name) {
return new Name(name.getNickname(), return new Name(name.getDisplay(),
name.getGiven(), name.getGiven(),
name.getFamily(), name.getFamily(),
name.getPrefix(), name.getPrefix(),
@ -507,15 +475,15 @@ public record MessageEnvelope(
) { ) {
static Address from(org.whispersystems.signalservice.api.messages.shared.SharedContact.PostalAddress address) { static Address from(org.whispersystems.signalservice.api.messages.shared.SharedContact.PostalAddress address) {
return new Address(Type.from(address.getType()), return new Address(Address.Type.from(address.getType()),
address.getLabel(), address.getLabel(),
address.getStreet(), address.getLabel(),
address.getPobox(), address.getLabel(),
address.getNeighborhood(), address.getLabel(),
address.getCity(), address.getLabel(),
address.getRegion(), address.getLabel(),
address.getPostcode(), address.getLabel(),
address.getCountry()); address.getLabel());
} }
public enum Type { public enum Type {
@ -534,47 +502,11 @@ public record MessageEnvelope(
} }
} }
public record PollCreate(
String question, boolean allowMultiple, List<String> options
) {
static PollCreate from(
SignalServiceDataMessage.PollCreate pollCreate
) {
return new PollCreate(pollCreate.getQuestion(), pollCreate.getAllowMultiple(), pollCreate.getOptions());
}
}
public record PollVote(
RecipientAddress targetAuthor, long targetSentTimestamp, List<Integer> optionIndexes, int voteCount
) {
static PollVote from(
SignalServiceDataMessage.PollVote pollVote,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new PollVote(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(pollVote.getTargetAuthor()))
.toApiRecipientAddress(),
pollVote.getTargetSentTimestamp(),
pollVote.getOptionIndexes(),
pollVote.getVoteCount());
}
}
public record PollTerminate(long targetSentTimestamp) {
static PollTerminate from(SignalServiceDataMessage.PollTerminate pollTerminate) {
return new PollTerminate(pollTerminate.getTargetSentTimestamp());
}
}
public record Preview(String title, String description, long date, String url, Optional<Attachment> image) { public record Preview(String title, String description, long date, String url, Optional<Attachment> image) {
static Preview from(SignalServicePreview preview, final AttachmentFileProvider fileProvider) { static Preview from(
SignalServicePreview preview, final AttachmentFileProvider fileProvider
) {
return new Preview(preview.getTitle(), return new Preview(preview.getTitle(),
preview.getDescription(), preview.getDescription(),
preview.getDate(), preview.getDate(),
@ -583,68 +515,44 @@ public record MessageEnvelope(
} }
} }
public record PinMessage( public record TextStyle(Style style, int start, int length) {
RecipientAddress targetAuthor, long targetSentTimestamp, long pinDurationSeconds
) {
static PinMessage from( public enum Style {
SignalServiceDataMessage.PinnedMessage pinnedMessage, NONE,
RecipientResolver recipientResolver, BOLD,
RecipientAddressResolver addressResolver ITALIC,
) { SPOILER,
return new PinMessage(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient( STRIKETHROUGH,
pinnedMessage.getTargetAuthor())).toApiRecipientAddress(), MONOSPACE;
pinnedMessage.getTargetSentTimestamp(),
Boolean.TRUE.equals(pinnedMessage.getForever()) static Style from(BodyRange.Style style) {
? -1 return switch (style) {
: pinnedMessage.getPinDurationInSeconds() == null case NONE -> NONE;
? 0 case BOLD -> BOLD;
: pinnedMessage.getPinDurationInSeconds()); case ITALIC -> ITALIC;
case SPOILER -> SPOILER;
case STRIKETHROUGH -> STRIKETHROUGH;
case MONOSPACE -> MONOSPACE;
};
} }
} }
public record UnpinMessage(RecipientAddress targetAuthor, long targetSentTimestamp) { static TextStyle from(BodyRange bodyRange) {
return new TextStyle(Style.from(bodyRange.getStyle()), bodyRange.getStart(), bodyRange.getLength());
static UnpinMessage from(
SignalServiceDataMessage.UnpinnedMessage unpinnedMessage,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new UnpinMessage(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
unpinnedMessage.getTargetAuthor())).toApiRecipientAddress(),
unpinnedMessage.getTargetSentTimestamp());
} }
} }
public record AdminDelete(RecipientAddress targetAuthor, long targetSentTimestamp) {
static AdminDelete from(
SignalServiceDataMessage.AdminDelete adminDelete,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new AdminDelete(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
adminDelete.getTargetAuthor())).toApiRecipientAddress(), adminDelete.getTargetSentTimestamp());
}
}
} }
public record Edit(long targetSentTimestamp, Data dataMessage) { public record Edit(long targetSentTimestamp, Data dataMessage) {
public static Edit from( public static Edit from(
final SignalServiceEditMessage editMessage, final SignalServiceEditMessage editMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
) { ) {
return new Edit(editMessage.getTargetSentTimestamp(), return new Edit(editMessage.getTargetSentTimestamp(),
Data.from(editMessage.getDataMessage(), Data.from(editMessage.getDataMessage(), recipientResolver, addressResolver, fileProvider));
longTexts,
recipientResolver,
addressResolver,
fileProvider));
} }
} }
@ -661,13 +569,12 @@ public record MessageEnvelope(
public static Sync from( public static Sync from(
final SignalServiceSyncMessage syncMessage, final SignalServiceSyncMessage syncMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
) { ) {
return new Sync(syncMessage.getSent() return new Sync(syncMessage.getSent()
.map(s -> Sent.from(s, longTexts, recipientResolver, addressResolver, fileProvider)), .map(s -> Sent.from(s, recipientResolver, addressResolver, fileProvider)),
syncMessage.getBlockedList().map(b -> Blocked.from(b, recipientResolver, addressResolver)), syncMessage.getBlockedList().map(b -> Blocked.from(b, recipientResolver, addressResolver)),
syncMessage.getRead() syncMessage.getRead()
.map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList()) .map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList())
@ -696,7 +603,6 @@ public record MessageEnvelope(
static Sent from( static Sent from(
SentTranscriptMessage sentMessage, SentTranscriptMessage sentMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
@ -712,17 +618,9 @@ public record MessageEnvelope(
.toApiRecipientAddress()) .toApiRecipientAddress())
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
sentMessage.getDataMessage() sentMessage.getDataMessage()
.map(message -> Data.from(message, .map(message -> Data.from(message, recipientResolver, addressResolver, fileProvider)),
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getEditMessage() sentMessage.getEditMessage()
.map(message -> Edit.from(message, .map(message -> Edit.from(message, recipientResolver, addressResolver, fileProvider)),
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getStoryMessage().map(s -> Story.from(s, fileProvider))); sentMessage.getStoryMessage().map(s -> Story.from(s, fileProvider)));
} }
} }
@ -734,15 +632,11 @@ public record MessageEnvelope(
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver RecipientAddressResolver addressResolver
) { ) {
return new Blocked(blockedListMessage.individuals.stream() return new Blocked(blockedListMessage.getAddresses()
.map(d -> new RecipientAddress(d.getAci() == null ? null : d.getAci().toString(), .stream()
null, .map(d -> addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(d))
d.getE164(), .toApiRecipientAddress())
null)) .toList(), blockedListMessage.getGroupIds().stream().map(GroupId::unknownVersion).toList());
.toList(),
blockedListMessage.groups.stream()
.map(group -> GroupId.unknownVersion(group.getGroupId()))
.toList());
} }
} }
@ -753,7 +647,7 @@ public record MessageEnvelope(
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver RecipientAddressResolver addressResolver
) { ) {
return new Read(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(readMessage.getSenderAci())) return new Read(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(readMessage.getSender()))
.toApiRecipientAddress(), readMessage.getTimestamp()); .toApiRecipientAddress(), readMessage.getTimestamp());
} }
} }
@ -816,9 +710,7 @@ public record MessageEnvelope(
DELETE, DELETE,
BLOCK, BLOCK,
BLOCK_AND_DELETE, BLOCK_AND_DELETE,
UNBLOCK_AND_ACCEPT, UNBLOCK_AND_ACCEPT;
SPAM,
BLOCK_AND_SPAM;
static Type from(MessageRequestResponseMessage.Type type) { static Type from(MessageRequestResponseMessage.Type type) {
return switch (type) { return switch (type) {
@ -828,8 +720,6 @@ public record MessageEnvelope(
case BLOCK -> BLOCK; case BLOCK -> BLOCK;
case BLOCK_AND_DELETE -> BLOCK_AND_DELETE; case BLOCK_AND_DELETE -> BLOCK_AND_DELETE;
case UNBLOCK_AND_ACCEPT -> UNBLOCK_AND_ACCEPT; case UNBLOCK_AND_ACCEPT -> UNBLOCK_AND_ACCEPT;
case SPAM -> SPAM;
case BLOCK_AND_SPAM -> BLOCK_AND_SPAM;
}; };
} }
} }
@ -845,8 +735,7 @@ public record MessageEnvelope(
Optional<Hangup> hangup, Optional<Hangup> hangup,
Optional<Busy> busy, Optional<Busy> busy,
List<IceUpdate> iceUpdate, List<IceUpdate> iceUpdate,
Optional<Opaque> opaque, Optional<Opaque> opaque
boolean isUrgent
) { ) {
public static Call from(final SignalServiceCallMessage callMessage) { public static Call from(final SignalServiceCallMessage callMessage) {
@ -860,14 +749,16 @@ public record MessageEnvelope(
callMessage.getIceUpdateMessages() callMessage.getIceUpdateMessages()
.map(m -> m.stream().map(IceUpdate::from).toList()) .map(m -> m.stream().map(IceUpdate::from).toList())
.orElse(List.of()), .orElse(List.of()),
callMessage.getOpaqueMessage().map(Opaque::from), callMessage.getOpaqueMessage().map(Opaque::from));
callMessage.isUrgent());
} }
public record Offer(long id, Type type, byte[] opaque) { public record Offer(long id, String sdp, Type type, byte[] opaque) {
static Offer from(OfferMessage offerMessage) { static Offer from(OfferMessage offerMessage) {
return new Offer(offerMessage.getId(), Type.from(offerMessage.getType()), offerMessage.getOpaque()); return new Offer(offerMessage.getId(),
offerMessage.getSdp(),
Type.from(offerMessage.getType()),
offerMessage.getOpaque());
} }
public enum Type { public enum Type {
@ -883,10 +774,10 @@ public record MessageEnvelope(
} }
} }
public record Answer(long id, byte[] opaque) { public record Answer(long id, String sdp, byte[] opaque) {
static Answer from(AnswerMessage answerMessage) { static Answer from(AnswerMessage answerMessage) {
return new Answer(answerMessage.getId(), answerMessage.getOpaque()); return new Answer(answerMessage.getId(), answerMessage.getSdp(), answerMessage.getOpaque());
} }
} }
@ -897,12 +788,13 @@ public record MessageEnvelope(
} }
} }
public record Hangup(long id, Type type, int deviceId) { public record Hangup(long id, Type type, int deviceId, boolean isLegacy) {
static Hangup from(HangupMessage hangupMessage) { static Hangup from(HangupMessage hangupMessage) {
return new Hangup(hangupMessage.getId(), return new Hangup(hangupMessage.getId(),
Type.from(hangupMessage.getType()), Type.from(hangupMessage.getType()),
hangupMessage.getDeviceId()); hangupMessage.getDeviceId(),
hangupMessage.isLegacy());
} }
public enum Type { public enum Type {
@ -924,10 +816,10 @@ public record MessageEnvelope(
} }
} }
public record IceUpdate(long id, byte[] opaque) { public record IceUpdate(long id, String sdp, byte[] opaque) {
static IceUpdate from(IceUpdateMessage iceUpdateMessage) { static IceUpdate from(IceUpdateMessage iceUpdateMessage) {
return new IceUpdate(iceUpdateMessage.getId(), iceUpdateMessage.getOpaque()); return new IceUpdate(iceUpdateMessage.getId(), iceUpdateMessage.getSdp(), iceUpdateMessage.getOpaque());
} }
} }
@ -958,7 +850,9 @@ public record MessageEnvelope(
Optional<TextAttachment> textAttachment Optional<TextAttachment> textAttachment
) { ) {
public static Story from(SignalServiceStoryMessage storyMessage, final AttachmentFileProvider fileProvider) { public static Story from(
SignalServiceStoryMessage storyMessage, final AttachmentFileProvider fileProvider
) {
return new Story(storyMessage.getAllowsReplies().orElse(false), return new Story(storyMessage.getAllowsReplies().orElse(false),
storyMessage.getGroupContext().map(c -> GroupUtils.getGroupIdV2(c.getMasterKey())), storyMessage.getGroupContext().map(c -> GroupUtils.getGroupIdV2(c.getMasterKey())),
storyMessage.getFileAttachment().map(f -> Data.Attachment.from(f, fileProvider)), storyMessage.getFileAttachment().map(f -> Data.Attachment.from(f, fileProvider)),
@ -976,8 +870,7 @@ public record MessageEnvelope(
) { ) {
static TextAttachment from( static TextAttachment from(
SignalServiceTextAttachment textAttachment, SignalServiceTextAttachment textAttachment, final AttachmentFileProvider fileProvider
final AttachmentFileProvider fileProvider
) { ) {
return new TextAttachment(textAttachment.getText(), return new TextAttachment(textAttachment.getText(),
textAttachment.getStyle().map(Style::from), textAttachment.getStyle().map(Style::from),
@ -1024,15 +917,13 @@ public record MessageEnvelope(
public static MessageEnvelope from( public static MessageEnvelope from(
SignalServiceEnvelope envelope, SignalServiceEnvelope envelope,
SignalServiceContent content, SignalServiceContent content,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider, final AttachmentFileProvider fileProvider,
Exception exception Exception exception
) { ) {
final var serviceId = envelope.getSourceServiceId(); final var source = !envelope.isUnidentifiedSender() && envelope.hasSourceUuid()
final var source = !envelope.isUnidentifiedSender() && serviceId != null ? recipientResolver.resolveRecipient(envelope.getSourceAddress())
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null : envelope.isUnidentifiedSender() && content != null
? recipientResolver.resolveRecipient(content.getSender()) ? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof ProtocolException e : exception instanceof ProtocolException e
@ -1055,15 +946,9 @@ public record MessageEnvelope(
receipt = content.getReceiptMessage().map(Receipt::from); receipt = content.getReceiptMessage().map(Receipt::from);
typing = content.getTypingMessage().map(Typing::from); typing = content.getTypingMessage().map(Typing::from);
data = content.getDataMessage() data = content.getDataMessage()
.map(dataMessage -> Data.from(dataMessage, .map(dataMessage -> Data.from(dataMessage, recipientResolver, addressResolver, fileProvider));
longTexts, edit = content.getEditMessage().map(s -> Edit.from(s, recipientResolver, addressResolver, fileProvider));
recipientResolver, sync = content.getSyncMessage().map(s -> Sync.from(s, recipientResolver, addressResolver, fileProvider));
addressResolver,
fileProvider));
edit = content.getEditMessage()
.map(s -> Edit.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
sync = content.getSyncMessage()
.map(s -> Sync.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
call = content.getCallMessage().map(Call::from); call = content.getCallMessage().map(Call::from);
story = content.getStoryMessage().map(s -> Story.from(s, fileProvider)); story = content.getStoryMessage().map(s -> Story.from(s, fileProvider));
} else { } else {

View File

@ -3,16 +3,5 @@ package org.asamk.signal.manager.api;
public enum PhoneNumberSharingMode { public enum PhoneNumberSharingMode {
EVERYBODY, EVERYBODY,
CONTACTS, CONTACTS,
NOBODY; NOBODY,
public static PhoneNumberSharingMode valueOfOrNull(String value) {
if (value == null) {
return null;
}
try {
return valueOf(value);
} catch (IllegalArgumentException ignored) {
return null;
}
}
} }

View File

@ -1,3 +0,0 @@
package org.asamk.signal.manager.api;
public class PinLockMissingException extends Exception {}

View File

@ -10,19 +10,12 @@ public class ProofRequiredException extends Exception {
private final String token; private final String token;
private final Set<Option> options; private final Set<Option> options;
private final long retryAfterMilliseconds; private final long retryAfterSeconds;
public ProofRequiredException(final String token, final Set<Option> options, final long retryAfterMilliseconds) { public ProofRequiredException(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
super("Rate limit"); this.token = e.getToken();
this.token = token; this.options = e.getOptions().stream().map(Option::from).collect(Collectors.toSet());
this.options = options; this.retryAfterSeconds = e.getRetryAfterSeconds();
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public static ProofRequiredException from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
return new ProofRequiredException(e.getToken(),
e.getOptions().stream().map(Option::from).collect(Collectors.toSet()),
e.getRetryAfterSeconds() * 1000L);
} }
public String getToken() { public String getToken() {
@ -33,17 +26,17 @@ public class ProofRequiredException extends Exception {
return options; return options;
} }
public long getRetryAfterMilliseconds() { public long getRetryAfterSeconds() {
return retryAfterMilliseconds; return retryAfterSeconds;
} }
public enum Option { public enum Option {
CAPTCHA, RECAPTCHA,
PUSH_CHALLENGE; PUSH_CHALLENGE;
static Option from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException.Option option) { static Option from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException.Option option) {
return switch (option) { return switch (option) {
case CAPTCHA -> CAPTCHA; case RECAPTCHA -> RECAPTCHA;
case PUSH_CHALLENGE -> PUSH_CHALLENGE; case PUSH_CHALLENGE -> PUSH_CHALLENGE;
}; };
} }

View File

@ -2,18 +2,14 @@ package org.asamk.signal.manager.api;
public class RateLimitException extends Exception { public class RateLimitException extends Exception {
private final Long retryAfterMilliseconds; private final long nextAttemptTimestamp;
public RateLimitException(final Long retryAfterMilliseconds) { public RateLimitException(final long nextAttemptTimestamp) {
super("Rate limit"); super("Rate limit");
this.retryAfterMilliseconds = retryAfterMilliseconds; this.nextAttemptTimestamp = nextAttemptTimestamp;
} }
public static RateLimitException from(org.whispersystems.signalservice.api.push.exceptions.RateLimitException e) { public long getNextAttemptTimestamp() {
return new RateLimitException(e.getRetryAfterMilliseconds().orElse(null)); return nextAttemptTimestamp;
}
public Long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
} }
} }

View File

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

View File

@ -1,5 +1,7 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.storage.recipients.Contact;
import org.asamk.signal.manager.storage.recipients.Profile;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
@ -20,16 +22,13 @@ public class Recipient {
private final Profile profile; private final Profile profile;
private final Boolean discoverable;
public Recipient( public Recipient(
final RecipientId recipientId, final RecipientId recipientId,
final RecipientAddress address, final RecipientAddress address,
final Contact contact, final Contact contact,
final ProfileKey profileKey, final ProfileKey profileKey,
final ExpiringProfileKeyCredential expiringProfileKeyCredential, final ExpiringProfileKeyCredential expiringProfileKeyCredential,
final Profile profile, final Profile profile
final Boolean discoverable
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.address = address; this.address = address;
@ -37,7 +36,6 @@ public class Recipient {
this.profileKey = profileKey; this.profileKey = profileKey;
this.expiringProfileKeyCredential = expiringProfileKeyCredential; this.expiringProfileKeyCredential = expiringProfileKeyCredential;
this.profile = profile; this.profile = profile;
this.discoverable = discoverable;
} }
private Recipient(final Builder builder) { private Recipient(final Builder builder) {
@ -45,9 +43,8 @@ public class Recipient {
address = builder.address; address = builder.address;
contact = builder.contact; contact = builder.contact;
profileKey = builder.profileKey; profileKey = builder.profileKey;
expiringProfileKeyCredential = builder.expiringProfileKeyCredential; expiringProfileKeyCredential = builder.expiringProfileKeyCredential1;
profile = builder.profile; profile = builder.profile;
discoverable = builder.discoverable;
} }
public static Builder newBuilder() { public static Builder newBuilder() {
@ -60,7 +57,7 @@ public class Recipient {
builder.address = copy.getAddress(); builder.address = copy.getAddress();
builder.contact = copy.getContact(); builder.contact = copy.getContact();
builder.profileKey = copy.getProfileKey(); builder.profileKey = copy.getProfileKey();
builder.expiringProfileKeyCredential = copy.getExpiringProfileKeyCredential(); builder.expiringProfileKeyCredential1 = copy.getExpiringProfileKeyCredential();
builder.profile = copy.getProfile(); builder.profile = copy.getProfile();
return builder; return builder;
} }
@ -89,10 +86,6 @@ public class Recipient {
return profile; return profile;
} }
public Boolean getDiscoverable() {
return discoverable;
}
@Override @Override
public boolean equals(final Object o) { public boolean equals(final Object o) {
if (this == o) return true; if (this == o) return true;
@ -117,9 +110,8 @@ public class Recipient {
private RecipientAddress address; private RecipientAddress address;
private Contact contact; private Contact contact;
private ProfileKey profileKey; private ProfileKey profileKey;
private ExpiringProfileKeyCredential expiringProfileKeyCredential; private ExpiringProfileKeyCredential expiringProfileKeyCredential1;
private Profile profile; private Profile profile;
private Boolean discoverable;
private Builder() { private Builder() {
} }
@ -145,7 +137,7 @@ public class Recipient {
} }
public Builder withExpiringProfileKeyCredential(final ExpiringProfileKeyCredential val) { public Builder withExpiringProfileKeyCredential(final ExpiringProfileKeyCredential val) {
expiringProfileKeyCredential = val; expiringProfileKeyCredential1 = val;
return this; return this;
} }
@ -154,11 +146,6 @@ public class Recipient {
return this; return this;
} }
public Builder withDiscoverable(final Boolean val) {
discoverable = val;
return this;
}
public Recipient build() { public Recipient build() {
return new Recipient(this); return new Recipient(this);
} }

View File

@ -1,55 +1,49 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.signal.core.util.UuidUtil; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public record RecipientAddress( public record RecipientAddress(Optional<UUID> uuid, Optional<String> number, Optional<String> username) {
Optional<String> aci, Optional<String> pni, Optional<String> number, Optional<String> username
) {
public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID; public static final UUID UNKNOWN_UUID = ServiceId.UNKNOWN.uuid();
/** /**
* Construct a RecipientAddress. * Construct a RecipientAddress.
* *
* @param aci The ACI of the user, if available. * @param uuid The UUID of the user, if available.
* @param pni The PNI of the user, if available.
* @param number The phone number of the user, if available. * @param number The phone number of the user, if available.
*/ */
public RecipientAddress { public RecipientAddress {
if (aci.isEmpty() && pni.isEmpty() && number.isEmpty() && username.isEmpty()) { uuid = uuid.isPresent() && uuid.get().equals(UNKNOWN_UUID) ? Optional.empty() : uuid;
throw new AssertionError("Must have either a ACI, PNI, username or E164 number!"); if (uuid.isEmpty() && number.isEmpty() && username.isEmpty()) {
throw new AssertionError("Must have either a UUID or E164 number!");
} }
} }
public RecipientAddress(String e164) { public RecipientAddress(UUID uuid, String e164) {
this(null, null, e164, null); this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(UUID uuid, String e164, String username) {
this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.ofNullable(username));
}
public RecipientAddress(SignalServiceAddress address) {
this(Optional.of(address.getServiceId().uuid()), address.getNumber(), Optional.empty());
} }
public RecipientAddress(UUID uuid) { public RecipientAddress(UUID uuid) {
this(uuid.toString(), null, null, null); this(Optional.of(uuid), Optional.empty(), Optional.empty());
}
public RecipientAddress(String aci, String pni, String e164, String username) {
this(Optional.ofNullable(aci),
Optional.ofNullable(pni),
Optional.ofNullable(e164),
Optional.ofNullable(username));
}
public Optional<UUID> uuid() {
return aci.map(UUID::fromString);
} }
public String getIdentifier() { public String getIdentifier() {
if (aci.isPresent()) { if (uuid.isPresent()) {
return aci.get(); return uuid.get().toString();
} else if (number.isPresent()) { } else if (number.isPresent()) {
return number.get(); return number.get();
} else if (pni.isPresent()) {
return pni.get();
} else if (username.isPresent()) { } else if (username.isPresent()) {
return username.get(); return username.get();
} else { } else {
@ -60,16 +54,17 @@ public record RecipientAddress(
public String getLegacyIdentifier() { public String getLegacyIdentifier() {
if (number.isPresent()) { if (number.isPresent()) {
return number.get(); return number.get();
} else if (uuid.isPresent()) {
return uuid.get().toString();
} else if (username.isPresent()) {
return username.get();
} else { } else {
return getIdentifier(); throw new AssertionError("Given the checks in the constructor, this should not be possible.");
} }
} }
public boolean matches(RecipientAddress other) { public boolean matches(RecipientAddress other) {
return (aci.isPresent() && other.aci.isPresent() && aci.get().equals(other.aci.get())) return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get()))
|| (
pni.isPresent() && other.pni.isPresent() && pni.get().equals(other.pni.get())
)
|| (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get())) || (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()))
|| (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get())); || (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get()));
} }

View File

@ -1,9 +1,10 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.util.PhoneNumberFormatter; import org.asamk.signal.manager.groups.GroupId;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.UUID; import java.util.UUID;
@ -24,18 +25,11 @@ public sealed interface RecipientIdentifier {
sealed interface Single extends RecipientIdentifier { sealed interface Single extends RecipientIdentifier {
static Single fromString(String identifier, String localNumber) throws InvalidNumberException { static Single fromString(String identifier, String localNumber) throws InvalidNumberException {
if (UuidUtil.INSTANCE.isUuid(identifier)) { try {
if (UuidUtil.isUuid(identifier)) {
return new Uuid(UUID.fromString(identifier)); return new Uuid(UUID.fromString(identifier));
} }
if (identifier.startsWith("PNI:")) {
final var pni = identifier.substring(4);
if (!UuidUtil.INSTANCE.isUuid(pni)) {
throw new InvalidNumberException("Invalid PNI");
}
return new Pni(UUID.fromString(pni));
}
if (identifier.startsWith("u:")) { if (identifier.startsWith("u:")) {
return new Username(identifier.substring(2)); return new Username(identifier.substring(2));
} }
@ -46,15 +40,16 @@ public sealed interface RecipientIdentifier {
logger.debug("Normalized number {} to {}.", identifier, normalizedNumber); logger.debug("Normalized number {} to {}.", identifier, normalizedNumber);
} }
return new Number(normalizedNumber); return new Number(normalizedNumber);
} catch (org.whispersystems.signalservice.api.util.InvalidNumberException e) {
throw new InvalidNumberException(e.getMessage(), e);
}
} }
static Single fromAddress(RecipientAddress address) { static Single fromAddress(RecipientAddress address) {
if (address.number().isPresent()) { if (address.number().isPresent()) {
return new Number(address.number().get()); return new Number(address.number().get());
} else if (address.aci().isPresent()) { } else if (address.uuid().isPresent()) {
return new Uuid(UUID.fromString(address.aci().get())); return new Uuid(address.uuid().get());
} else if (address.pni().isPresent()) {
return new Pni(UUID.fromString(address.pni().get().substring(4)));
} else if (address.username().isPresent()) { } else if (address.username().isPresent()) {
return new Username(address.username().get()); return new Username(address.username().get());
} }
@ -77,19 +72,6 @@ public sealed interface RecipientIdentifier {
} }
} }
record Pni(UUID pni) implements Single {
@Override
public String getIdentifier() {
return "PNI:" + pni.toString();
}
@Override
public RecipientAddress toPartialRecipientAddress() {
return new RecipientAddress(null, getIdentifier(), null, null);
}
}
record Number(String number) implements Single { record Number(String number) implements Single {
@Override @Override
@ -99,7 +81,7 @@ public sealed interface RecipientIdentifier {
@Override @Override
public RecipientAddress toPartialRecipientAddress() { public RecipientAddress toPartialRecipientAddress() {
return new RecipientAddress(number); return new RecipientAddress(null, number);
} }
} }
@ -112,7 +94,7 @@ public sealed interface RecipientIdentifier {
@Override @Override
public RecipientAddress toPartialRecipientAddress() { public RecipientAddress toPartialRecipientAddress() {
return new RecipientAddress(null, null, null, username); return new RecipientAddress(null, null, username);
} }
} }

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver; import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.libsignal.protocol.IdentityKey;
public record SendMessageResult( public record SendMessageResult(
RecipientAddress address, RecipientAddress address,
@ -9,13 +10,30 @@ public record SendMessageResult(
boolean isNetworkFailure, boolean isNetworkFailure,
boolean isUnregisteredFailure, boolean isUnregisteredFailure,
boolean isIdentityFailure, boolean isIdentityFailure,
RateLimitException rateLimitException, boolean isRateLimitFailure,
ProofRequiredException proofRequiredFailure, ProofRequiredException proofRequiredFailure
boolean isInvalidPreKeyFailure
) { ) {
public static SendMessageResult success(RecipientAddress address) {
return new SendMessageResult(address, true, false, false, false, false, null);
}
public static SendMessageResult networkFailure(RecipientAddress address) {
return new SendMessageResult(address, false, true, false, false, false, null);
}
public static SendMessageResult unregisteredFailure(RecipientAddress address) { public static SendMessageResult unregisteredFailure(RecipientAddress address) {
return new SendMessageResult(address, false, false, true, false, null, null, false); return new SendMessageResult(address, false, false, true, false, false, null);
}
public static SendMessageResult identityFailure(RecipientAddress address, IdentityKey identityKey) {
return new SendMessageResult(address, false, false, false, true, false, null);
}
public static SendMessageResult proofRequiredFailure(
RecipientAddress address, ProofRequiredException proofRequiredException
) {
return new SendMessageResult(address, false, true, false, false, false, proofRequiredException);
} }
public static SendMessageResult from( public static SendMessageResult from(
@ -23,30 +41,15 @@ public record SendMessageResult(
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver RecipientAddressResolver addressResolver
) { ) {
final var rateLimitFailure = sendMessageResult.getRateLimitFailure();
final var proofRequiredFailure = sendMessageResult.getProofRequiredFailure();
return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient( return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
sendMessageResult.getAddress())).toApiRecipientAddress(), sendMessageResult.getAddress())).toApiRecipientAddress(),
sendMessageResult.isSuccess(), sendMessageResult.isSuccess(),
sendMessageResult.isNetworkFailure(), sendMessageResult.isNetworkFailure(),
sendMessageResult.isUnregisteredFailure(), sendMessageResult.isUnregisteredFailure(),
sendMessageResult.getIdentityFailure() != null, sendMessageResult.getIdentityFailure() != null,
rateLimitFailure == null ? null : RateLimitException.from(rateLimitFailure), sendMessageResult.getRateLimitFailure() != null || sendMessageResult.getProofRequiredFailure() != null,
proofRequiredFailure == null ? null : ProofRequiredException.from(proofRequiredFailure), sendMessageResult.getProofRequiredFailure() == null
sendMessageResult.isInvalidPreKeyFailure()); ? null
} : new ProofRequiredException(sendMessageResult.getProofRequiredFailure()));
public boolean isRateLimitFailure() {
return this.rateLimitException != null || this.proofRequiredFailure != null;
}
public Long rateLimitRetryAfterMilliseconds() {
if (proofRequiredFailure != null) {
return proofRequiredFailure.getRetryAfterMilliseconds();
} else if (rateLimitException != null) {
return rateLimitException.getRetryAfterMilliseconds();
} else {
return null;
}
} }
} }

View File

@ -2,7 +2,6 @@ package org.asamk.signal.manager.api;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) { public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) {
@ -20,25 +19,4 @@ public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<S
.allMatch(identityFailure -> identityFailure) .allMatch(identityFailure -> identityFailure)
&& results.values().stream().mapToInt(List::size).sum() > 0; && results.values().stream().mapToInt(List::size).sum() > 0;
} }
public boolean hasOnlyRateLimitFailure() {
return results.values()
.stream()
.flatMap(res -> res.stream().map(SendMessageResult::isRateLimitFailure))
.allMatch(r -> r) && results.values().stream().mapToInt(List::size).sum() > 0;
}
/**
* Longest rate-limit retry-after window across all rate-limited recipients, in milliseconds.
* Null when no recipient reported one (server omitted Retry-After, or no rate-limit failures).
*/
public Long maxRateLimitRetryAfterMilliseconds() {
return results.values()
.stream()
.flatMap(List::stream)
.map(SendMessageResult::rateLimitRetryAfterMilliseconds)
.filter(Objects::nonNull)
.max(Long::compareTo)
.orElse(null);
}
} }

View File

@ -1,7 +1,5 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.signal.core.util.Hex;
import java.util.Arrays; import java.util.Arrays;
public class StickerPackId { public class StickerPackId {
@ -34,9 +32,4 @@ public class StickerPackId {
public int hashCode() { public int hashCode() {
return Arrays.hashCode(id); return Arrays.hashCode(id);
} }
@Override
public String toString() {
return "StickerPackId{" + Hex.toStringCondensed(id) + '}';
}
} }

View File

@ -1,7 +1,7 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.core.util.Hex; import org.whispersystems.signalservice.internal.util.Hex;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@ -11,7 +11,10 @@ import java.nio.charset.StandardCharsets;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty; import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public record StickerPackUrl(StickerPackId packId, byte[] packKey) { public final class StickerPackUrl {
private final StickerPackId packId;
private final byte[] packKey;
/** /**
* @throws InvalidStickerPackLinkException If url cannot be parsed. * @throws InvalidStickerPackLinkException If url cannot be parsed.
@ -19,7 +22,7 @@ public record StickerPackUrl(StickerPackId packId, byte[] packKey) {
public static StickerPackUrl fromUri(URI uri) throws InvalidStickerPackLinkException { public static StickerPackUrl fromUri(URI uri) throws InvalidStickerPackLinkException {
final var rawQuery = uri.getRawFragment(); final var rawQuery = uri.getRawFragment();
if (isEmpty(rawQuery)) { if (isEmpty(rawQuery)) {
throw new InvalidStickerPackLinkException("Invalid sticker pack uri"); throw new RuntimeException("Invalid sticker pack uri");
} }
var query = Utils.getQueryMap(rawQuery); var query = Utils.getQueryMap(rawQuery);
@ -45,6 +48,11 @@ public record StickerPackUrl(StickerPackId packId, byte[] packKey) {
return new StickerPackUrl(packId, packKey); return new StickerPackUrl(packId, packKey);
} }
public StickerPackUrl(final StickerPackId packId, final byte[] packKey) {
this.packId = packId;
this.packKey = packKey;
}
public URI getUrl() { public URI getUrl() {
try { try {
return new URI("https", return new URI("https",
@ -59,7 +67,15 @@ public record StickerPackUrl(StickerPackId packId, byte[] packKey) {
} }
} }
public static final class InvalidStickerPackLinkException extends Exception { public StickerPackId getPackId() {
return packId;
}
public byte[] getPackKey() {
return packKey;
}
public final static class InvalidStickerPackLinkException extends Exception {
public InvalidStickerPackLinkException(String message) { public InvalidStickerPackLinkException(String message) {
super(message); super(message);

View File

@ -1,63 +0,0 @@
package org.asamk.signal.manager.api;
import org.whispersystems.signalservice.internal.push.BodyRange;
public record TextStyle(Style style, Integer start, Integer length) {
public enum Style {
NONE,
BOLD,
ITALIC,
SPOILER,
STRIKETHROUGH,
MONOSPACE;
static Style fromInternal(BodyRange.Style style) {
if (style == null) {
return NONE;
}
return switch (style) {
case NONE -> NONE;
case BOLD -> BOLD;
case ITALIC -> ITALIC;
case SPOILER -> SPOILER;
case STRIKETHROUGH -> STRIKETHROUGH;
case MONOSPACE -> MONOSPACE;
};
}
public static Style from(String style) {
return switch (style) {
case "NONE" -> NONE;
case "BOLD" -> BOLD;
case "ITALIC" -> ITALIC;
case "SPOILER" -> SPOILER;
case "STRIKETHROUGH" -> STRIKETHROUGH;
case "MONOSPACE" -> MONOSPACE;
default -> null;
};
}
BodyRange.Style toBodyRangeStyle() {
return switch (this) {
case NONE -> BodyRange.Style.NONE;
case BOLD -> BodyRange.Style.BOLD;
case ITALIC -> BodyRange.Style.ITALIC;
case SPOILER -> BodyRange.Style.SPOILER;
case STRIKETHROUGH -> BodyRange.Style.STRIKETHROUGH;
case MONOSPACE -> BodyRange.Style.MONOSPACE;
};
}
}
static TextStyle from(BodyRange bodyRange) {
return new TextStyle(Style.fromInternal(bodyRange.style), bodyRange.start, bodyRange.length);
}
public BodyRange toBodyRange() {
return new BodyRange.Builder().start(this.start())
.length(this.length())
.style(this.style().toBodyRangeStyle())
.build();
}
}

View File

@ -1,6 +1,7 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
public enum TrustLevel { public enum TrustLevel {
UNTRUSTED, UNTRUSTED,
@ -16,6 +17,15 @@ public enum TrustLevel {
return TrustLevel.cachedValues[i]; return TrustLevel.cachedValues[i];
} }
public static TrustLevel fromIdentityState(ContactRecord.IdentityState identityState) {
return switch (identityState) {
case DEFAULT -> TRUSTED_UNVERIFIED;
case UNVERIFIED -> UNTRUSTED;
case VERIFIED -> TRUSTED_VERIFIED;
case UNRECOGNIZED -> null;
};
}
public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) { public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) {
return switch (verifiedState) { return switch (verifiedState) {
case DEFAULT -> TRUSTED_UNVERIFIED; case DEFAULT -> TRUSTED_UNVERIFIED;

View File

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

Some files were not shown because too many files have changed in this diff Show More