Compare commits

..

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

562 changed files with 12529 additions and 71303 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

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

@ -0,0 +1,20 @@
name: signal-cli CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '11', '14' ]
steps:
- uses: actions/checkout@v1
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: ${{ matrix.java }}
- name: Build with Gradle
run: ./gradlew build

View File

@ -2,17 +2,13 @@ name: "CodeQL"
on: on:
push: push:
branches: [ master ] branches: [master, ]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [master] branches: [master]
schedule: schedule:
- cron: '0 7 * * 4' - cron: '0 7 * * 4'
permissions:
contents: read # to fetch code (actions/checkout)
security-events: write
jobs: jobs:
analyse: analyse:
name: Analyse name: Analyse
@ -21,21 +17,25 @@ jobs:
steps: steps:
- name: Setup Java JDK - name: Setup Java JDK
uses: actions/setup-java@v5 uses: actions/setup-java@v1
with: with:
distribution: 'zulu' java-version: 11
java-version: 25
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v2
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.
fetch-depth: 2 fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# 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@v1
# 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@v1
# 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@v1

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

@ -1,172 +0,0 @@
name: release
on:
push:
tags:
- v*
permissions: {}
env:
IMAGE_NAME: signal-cli
IMAGE_REGISTRY: ghcr.io/asamk
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }}
ARCHIVE_JAVA_VERSION: 25
jobs:
build:
uses: ./.github/workflows/build.yml
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: version
run: |
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create draft release and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.version.outputs.version }}" \
--repo "${{ github.repository }}" \
--title "v${{ steps.version.outputs.version }}" \
--draft \
--verify-tag \
"signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \
"signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz"
build-container:
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 }}.tar.gz
mkdir -p build/install/
mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: ./Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v3
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-native:
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-native.tar.gz
mkdir -p build/native/nativeCompile/
mv signal-cli build/native/nativeCompile/
chmod +x build/native/nativeCompile/signal-cli
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v3
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: ./native.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v3
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
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) }}"

10
.gitignore vendored
View File

@ -1,5 +1,4 @@
.gradle/ .gradle/
.kotlin/
.idea/* .idea/*
!.idea/codeStyles/ !.idea/codeStyles/
build/ build/
@ -12,12 +11,3 @@ local.properties
.settings/ .settings/
out/ out/
.DS_Store .DS_Store
/bin/
/test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5
.superpowers/

View File

@ -4,9 +4,8 @@
<JavaCodeStyleSettings> <JavaCodeStyleSettings>
<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="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50" />
<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" /> <option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50" />
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99" />
<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" />
@ -27,14 +26,29 @@
<emptyLine /> <emptyLine />
</value> </value>
</option> </option>
<option name="RECORD_COMPONENTS_WRAP" value="5" />
<option name="NEW_LINE_AFTER_LPAREN_IN_RECORD_HEADER" value="true" />
<option name="RPAREN_ON_NEW_LINE_IN_RECORD_HEADER" value="true" />
<option name="JD_P_AT_EMPTY_LINES" value="false" /> <option name="JD_P_AT_EMPTY_LINES" value="false" />
</JavaCodeStyleSettings> </JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
</JetCodeStyleSettings> </JetCodeStyleSettings>
<XML>
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<codeStyleSettings language="JAVA"> <codeStyleSettings language="JAVA">
<option name="RIGHT_MARGIN" value="120" /> <option name="RIGHT_MARGIN" value="120" />
<option name="KEEP_LINE_BREAKS" value="false" /> <option name="KEEP_LINE_BREAKS" value="false" />
@ -53,10 +67,6 @@
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" /> <option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="5" /> <option name="TERNARY_OPERATION_WRAP" value="5" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" /> <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="KEEP_SIMPLE_CLASSES_IN_ONE_LINE" value="true" />
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" />
<option name="ENUM_CONSTANTS_WRAP" value="2" /> <option name="ENUM_CONSTANTS_WRAP" value="2" />
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">
@ -64,8 +74,5 @@
<rules /> <rules />
</arrangement> </arrangement>
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
# Question
If you have a question you can ask it in the [GitHub discussions page](https://github.com/AsamK/signal-cli/discussions)
# Report a bug
- Search [existing issues](https://github.com/AsamK/signal-cli/issues?q=is%3Aissue) if it has been reported already
- If you're unable to find an open issue addressing the
problem, [open a new one](https://github.com/AsamK/signal-cli/issues/new).
- 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 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
# Pull request
- Code style should match the existing code, IntelliJ users can use the auto formatter
- Separate PRs should be opened for each implemented feature or bug fix

View File

@ -1,11 +0,0 @@
FROM docker.io/azul/zulu-openjdk:25-jre-headless
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 --create-home --home-dir /var/lib/signal-cli
ADD build/install/signal-cli /opt/signal-cli
USER signal-cli
ENTRYPOINT ["/opt/signal-cli/bin/signal-cli", "--config=/var/lib/signal-cli"]

View File

@ -1,4 +0,0 @@
github: AsamK
liberapay: asamk
ko_fi: asamk
#bitcoin: bc1qykae53fry8a8ycgdzgv0rlxfc959hmmllvz698

162
README.md
View File

@ -1,138 +1,76 @@
# signal-cli # signal-cli
signal-cli is a commandline interface for the [Signal messenger](https://signal.org/). signal-cli is a commandline interface for [libsignal-service-java](https://github.com/WhisperSystems/libsignal-service-java). It supports registering, verifying, sending and receiving messages.
It supports registering, verifying, sending and receiving messages. To be able to link to an existing Signal-Android/signal-cli instance, signal-cli uses a [patched libsignal-service-java](https://github.com/AsamK/libsignal-service-java), because libsignal-service-java does not yet support [provisioning as a slave device](https://github.com/WhisperSystems/libsignal-service-java/pull/21).
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).
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. For this use-case, it has a dbus interface, that can be used to send messages from any programming language that has dbus bindings.
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 JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
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.
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 the [provided binary files](https://github.com/AsamK/signal-cli/releases/latest), which should work on Linux, macOS and Windows. For Arch Linux there is also a [package in AUR](https://aur.archlinux.org/packages/signal-cli/) and there is a [FreeBSD port](https://www.freshports.org/net-im/signal-cli) available as well.
the [provided binary files](https://github.com/AsamK/signal-cli/releases/latest), which should work on Linux, macOS and
Windows. There's also a [docker image and some Linux packages](https://github.com/AsamK/signal-cli/wiki/Binary-distributions) provided by the community.
System requirements: System requirements:
- at least Java Runtime Environment (JRE) 11
- native libraries: libzkgroup, libsignal-client
Those are bundled for x86_64 Linux, for other systems/architectures see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal)
- at least Java Runtime Environment (JRE) 25 ### Install system-wide on Linux
- native library: libsignal-client
The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other
systems/architectures
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 ]
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}".tar.gz
sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt sudo tar xf signal-cli-"${VERSION}".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
For a complete usage overview please read For a complete usage overview please read the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/AsamK/signal-cli/wiki).
the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and
the [wiki](https://github.com/AsamK/signal-cli/wiki).
Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it Important: The USERNAME is your phone number in international format and must include the country calling code. Hence it should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.)
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
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 signal-cli -u USERNAME register
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 You can register Signal using a land line number. In this case you can skip SMS verification process and jump directly to the voice call verification by adding the --voice switch at the end of above register command.
You can register Signal using a landline number. In this case, you need to follow the procedure below: * Verify the number using the code received via SMS or voice, optionally add `--pin PIN_CODE` if you've added a pin code to your account
* Attempt a SMS verification process first (`signal-cli -a ACCOUNT register`)
* 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 -u USERNAME verify CODE
signal-cli -a ACCOUNT register --voice
```
Registering may require solving a CAPTCHA
challenge: [Registration with captcha](https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha)
* Verify the number using the code received via SMS or voice, optionally add `--pin PIN_CODE` if you've added a pin code
to your account
signal-cli -a ACCOUNT verify CODE
* Send a message * Send a message
```sh signal-cli -u USERNAME 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.
uname -a | signal-cli -a ACCOUNT send --message-from-stdin RECIPIENT uname -a | signal-cli -u USERNAME send RECIPIENT
* Receive messages * Receive messages
signal-cli -a ACCOUNT receive signal-cli -u USERNAME receive
**Hint**: The Signal protocol expects that incoming messages are regularly received (using `daemon` or `receive`
command). This is required for the encryption to work efficiently and for getting updates to groups, expiration timer
and other features.
## Storage ## Storage
The password and cryptographic keys are created when registering and stored in the current users home directory: The password and cryptographic keys are created when registering and stored in the current users home directory:
$XDG_DATA_HOME/signal-cli/data/ `$XDG_DATA_HOME/signal-cli/data/` (`$HOME/.local/share/signal-cli/data/`)
$HOME/.local/share/signal-cli/data/
For legacy users, the old config directories are used as a fallback:
$HOME/.config/signal/data/
$HOME/.config/textsecure/data/
## Building ## Building
This project uses [Gradle](http://gradle.org) for building and maintaining dependencies. If you have a recent gradle This project uses [Gradle](http://gradle.org) for building and maintaining
version installed, you can replace `./gradlew` with `gradle` in the following steps. dependencies. If you have a recent gradle version installed, you can replace `./gradlew` with `gradle` in the following steps.
1. Checkout the source somewhere on your filesystem with 1. Checkout the source somewhere on your filesystem with
@ -142,49 +80,29 @@ version installed, you can replace `./gradlew` with `gradle` in the following st
./gradlew build ./gradlew build
2a. Create shell wrapper in *build/install/signal-cli/bin*: 3. Create shell wrapper in *build/install/signal-cli/bin*:
./gradlew installDist ./gradlew installDist
2b. Create tar file in *build/distributions*: 4. Create tar file in *build/distributions*:
./gradlew distTar ./gradlew distTar
2c. Create a fat tar file in *build/libs/signal-cli-fat*:
./gradlew fatJar
2d. Compile and run signal-cli:
```sh
./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).
work in all situations. This is still experimental and will not 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 assembleNativeImage
The binary is available at *build/native/nativeCompile/signal-cli* The binary is available at *build/native-image/signal-cli*
## FAQ and Troubleshooting ## Troubleshooting
If you use a version of the Oracle JRE and get an InvalidKeyException you need to enable unlimited strength crypto. See https://stackoverflow.com/questions/6481627/java-security-illegal-key-size-or-default-parameters for instructions.
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,117 +1,32 @@
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"
} }
allprojects { version = "0.8.0"
group = "org.asamk"
version = "0.14.8"
}
java { java {
sourceCompatibility = JavaVersion.VERSION_25 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_11
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 { repositories {
binaries { mavenLocal()
this["main"].run { mavenCentral()
buildArgs.add("-Dfile.encoding=UTF-8")
buildArgs.add("-J-Dfile.encoding=UTF-8")
buildArgs.add("-march=compatibility")
buildArgs.add("--enable-native-access=ALL-UNNAMED")
resources.autodetect()
if (System.getenv("GRAALVM_HOME") == null) {
toolchainDetection.set(true)
javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(25))
})
} else {
toolchainDetection.set(false)
}
}
}
}
val artifactType = Attribute.of("artifactType", String::class.java)
val minified = Attribute.of("minified", Boolean::class.javaObjectType)
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 { dependencies {
registerTransform(JarFileExcluder::class) { implementation("org.bouncycastle:bcprov-jdk15on:1.68")
from.attribute(minified, false).attribute(artifactType, "jar") implementation("net.sourceforge.argparse4j:argparse4j:0.8.1")
to.attribute(minified, true).attribute(artifactType, "jar") implementation("com.github.hypfvieh:dbus-java:3.2.4")
implementation("org.slf4j:slf4j-simple:1.7.30")
parameters { implementation(project(":lib"))
excludeFilesByArtifact = excludePatterns
}
}
schemaAnnotationProcessor(libs.micronaut.json.schema.processor)
schemaAnnotationProcessor(libs.micronaut.inject.java)
implementation(libs.bouncycastle)
implementation(libs.jackson.databind)
implementation(libs.argparse4j)
implementation(libs.dbusjava)
implementation(libs.slf4j.api)
implementation(libs.slf4j.jul)
implementation(libs.logback)
implementation(libs.zxing)
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 {
@ -120,12 +35,6 @@ configurations {
} }
} }
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
tasks.withType<JavaCompile> { tasks.withType<JavaCompile> {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
@ -135,68 +44,53 @@ 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) { tasks.withType<JavaExec> {
archiveBaseName.set("${project.name}-fat") val appArgs: String? by project
exclude( if (appArgs != null) {
"META-INF/*.SF", // allow passing command-line arguments to the main application e.g.:
"META-INF/**/*.MF", // $ gradle run -PappArgs="['-u', '+...', 'daemon', '--json']"
"META-INF/*.DSA", args = groovy.util.Eval.me(appArgs) as MutableList<String>
"META-INF/*.RSA", }
"META-INF/NOTICE*", }
"META-INF/LICENSE*",
"META-INF/INDEX.LIST", val assembleNativeImage by tasks.registering {
"**/module-info.class", dependsOn("assemble")
)
duplicatesStrategy = DuplicatesStrategy.WARN var graalVMHome = ""
doFirst { doFirst {
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) graalVMHome = System.getenv("GRAALVM_HOME")
} ?: throw GradleException("Required GRAALVM_HOME environment variable not set.")
with(tasks.jar.get())
} }
tasks.register("writeLibsignalVersion") {
doLast { doLast {
val resolutionResult = configurations.runtimeClasspath.get().incoming.resolutionResult val nativeBinaryOutputPath = "$buildDir/native-image"
val libsignalDep = val nativeBinaryName = "signal-cli"
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") { mkdir(nativeBinaryOutputPath)
dependsOn(tasks.compileJava)
val schemaBaseUri = "http://localhost:8080/schemas/" exec {
source = sourceSets.main.get().java workingDir = File(".")
include("org/asamk/signal/json/**/*.java") commandLine("$graalVMHome/bin/native-image",
classpath = sourceSets.main.get().compileClasspath + files(sourceSets.main.get().java.destinationDirectory) "-H:Path=$nativeBinaryOutputPath",
destinationDirectory.set(layout.buildDirectory.dir("generated")) "-H:Name=$nativeBinaryName",
options.annotationProcessorPath = schemaAnnotationProcessor "-H:JNIConfigurationFiles=graalvm-config-dir/jni-config.json",
options.compilerArgs.addAll( "-H:DynamicProxyConfigurationFiles=graalvm-config-dir/proxy-config.json",
listOf( "-H:ResourceConfigurationFiles=graalvm-config-dir/resource-config.json",
"-Amicronaut.processing.group=org.asamk", "-H:ReflectionConfigurationFiles=graalvm-config-dir/reflect-config.json",
"-Amicronaut.processing.module=signal-cli", "--no-fallback",
"-Amicronaut.processing.annotations=org.asamk.signal.json.*", "--allow-incomplete-classpath",
"-Amicronaut.jsonschema.baseUri=$schemaBaseUri", "--report-unsupported-elements-at-runtime",
) "--enable-url-protocols=http,https",
) "--enable-https",
doLast { "--enable-all-security-services",
fileTree(destinationDirectory.get().dir("META-INF/schemas").asFile) { "-cp",
include("*.schema.json") sourceSets.main.get().runtimeClasspath.asPath,
}.forEach { schemaFile -> application.mainClass.get())
val normalized = schemaFile.readText().replace("\"$schemaBaseUri/", "\"")
val prettyJson = JsonOutput.prettyPrint(normalized)
schemaFile.writeText("$prettyJson\n")
} }
} }
} }

View File

@ -1,21 +1,9 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
plugins { plugins {
`kotlin-dsl` `kotlin-dsl`
} }
tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_25)
}
java {
targetCompatibility = JavaVersion.VERSION_25
}
repositories { repositories {
mavenCentral() jcenter()
} }
gradlePlugin { gradlePlugin {

View File

@ -1,14 +1,15 @@
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 {
project.configurations.flatMap { it.allDependencies } project.configurations.flatMap { it.allDependencies }
.toSet() .toSet()
@ -24,15 +25,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"]

1
client/.gitignore vendored
View File

@ -1 +0,0 @@
/target/

1664
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
[package]
name = "signal-cli-client"
version = "0.0.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["cargo", "derive", "wrap_help"] }
serde = "1"
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros", "net", "rt-multi-thread"] }
jsonrpsee = { version = "0.26", features = [
"macros",
"async-client",
"http-client",
] }
bytes = "1"
tokio-util = "0.7"
futures-util = "0.3"
thiserror = "2"

View File

@ -1,669 +0,0 @@
use std::{ffi::OsString, net::SocketAddr};
use clap::{crate_version, Parser, Subcommand, ValueEnum};
/// JSON-RPC client for signal-cli
#[derive(Parser, Debug)]
#[command(rename_all = "kebab-case", version = crate_version!())]
pub struct Cli {
/// Account to use (for daemon in multi-account mode)
#[arg(short = 'a', long)]
pub account: Option<String>,
#[arg(long)]
pub output: Option<String>,
/// TCP host and port of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_http")]
pub json_rpc_tcp: Option<Option<SocketAddr>>,
/// UNIX socket address and port of signal-cli daemon
#[cfg(unix)]
#[arg(long, conflicts_with = "json_rpc_tcp")]
pub json_rpc_socket: Option<Option<OsString>>,
/// HTTP URL of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_socket")]
pub json_rpc_http: Option<Option<String>>,
#[arg(long)]
pub verbose: bool,
#[command(subcommand)]
pub command: CliCommands,
}
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand, Debug)]
#[command(rename_all = "camelCase", version = crate_version!())]
pub enum CliCommands {
AddDevice {
#[arg(long)]
uri: String,
},
AddStickerPack {
#[arg(long)]
uri: String,
},
#[command(rename_all = "kebab-case")]
Block {
recipient: Vec<String>,
#[arg(short = 'g', long)]
group_id: Vec<String>,
},
DeleteLocalAccountData {
#[arg(long = "ignore-registered")]
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 {
recipient: Vec<String>,
#[arg(long)]
username: Vec<String>,
},
JoinGroup {
#[arg(long)]
uri: String,
},
Link {
#[arg(short = 'n', long)]
name: Option<String>,
},
ListAccounts,
ListContacts {
recipient: Vec<String>,
#[arg(short = 'a', long = "all-recipients")]
all_recipients: bool,
#[arg(long)]
blocked: Option<bool>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
detailed: bool,
#[arg(long)]
internal: bool,
},
ListDevices,
ListGroups {
#[arg(short = 'd', long)]
detailed: bool,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
},
ListIdentities {
#[arg(short = 'n', long)]
number: Option<String>,
},
ListStickerPacks,
QuitGroup {
#[arg(short = 'g', long = "group-id")]
group_id: String,
#[arg(long)]
delete: bool,
#[arg(long)]
admin: Vec<String>,
},
Receive {
#[arg(short = 't', long, default_value_t = 3.0)]
timeout: f64,
},
Register {
#[arg(short = 'v', long)]
voice: bool,
#[arg(long)]
captcha: Option<String>,
#[arg(long)]
reregister: bool,
},
RemoveContact {
recipient: String,
#[arg(long)]
forget: bool,
#[arg(long)]
hide: bool,
},
RemoveDevice {
#[arg(short = 'd', long = "device-id")]
device_id: u32,
},
RemovePin,
RemoteDelete {
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(long = "note-to-self")]
note_to_self: bool,
},
#[command(rename_all = "kebab-case")]
Send {
recipient: Vec<String>,
#[arg(short = 'g', long)]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long)]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
end_session: bool,
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
message_from_stdin: bool,
#[arg(short = 'a', long)]
attachment: Vec<String>,
#[arg(long)]
view_once: bool,
#[arg(long)]
mention: Vec<String>,
#[arg(long)]
text_style: Vec<String>,
#[arg(long)]
quote_timestamp: Option<u64>,
#[arg(long)]
quote_author: Option<String>,
#[arg(long)]
quote_message: Option<String>,
#[arg(long)]
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)]
sticker: Option<String>,
#[arg(long)]
story_timestamp: Option<u64>,
#[arg(long)]
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,
SendPaymentNotification {
recipient: String,
#[arg(long)]
receipt: String,
#[arg(long)]
note: String,
},
SendPinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(short = 'd', long = "pin-duration")]
pin_duration: Option<i32>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendPollCreate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'q', long = "question")]
question: String,
#[arg(short = 'o', long = "option")]
option: Vec<String>,
#[arg(long = "no-multi")]
no_multi: bool,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollTerminate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollVote {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-author")]
poll_author: Option<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(short = 'o', long = "option")]
option: Vec<i32>,
#[arg(long = "vote-count")]
vote_count: i32,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendReaction {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
emoji: String,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(short = 'r', long)]
remove: bool,
#[arg(long)]
story: bool,
},
SendReceipt {
recipient: String,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: Vec<u64>,
#[arg(value_enum, long)]
r#type: ReceiptType,
},
SendSyncRequest,
SendTyping {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 's', long)]
stop: bool,
},
SendUnpinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendMessageRequestResponse {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(long)]
r#type: MessageRequestResponseType,
},
SetPin {
pin: String,
},
StartChangeNumber {
number: String,
#[arg(short = 'v', long)]
voice: bool,
#[arg(long)]
captcha: Option<String>,
},
SubmitRateLimitChallenge {
challenge: String,
captcha: String,
},
Trust {
recipient: String,
#[arg(short = 'a', long = "trust-all-known-keys")]
trust_all_known_keys: bool,
#[arg(short = 'v', long = "verified-safety-number")]
verified_safety_number: Option<String>,
},
#[command(rename_all = "kebab-case")]
Unblock {
recipient: Vec<String>,
#[arg(short = 'g', long)]
group_id: Vec<String>,
},
Unregister {
#[arg(long = "delete-account")]
delete_account: bool,
},
UpdateAccount {
#[arg(short = 'n', long = "device-name")]
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 {
#[arg(long = "read-receipts")]
read_receipts: Option<bool>,
#[arg(long = "unidentified-delivery-indicators")]
unidentified_delivery_indicators: Option<bool>,
#[arg(long = "typing-indicators")]
typing_indicators: Option<bool>,
#[arg(long = "link-previews")]
link_previews: Option<bool>,
},
UpdateContact {
recipient: String,
#[arg(short = 'e', long)]
expiration: Option<u32>,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(long = "given-name")]
given_name: Option<String>,
#[arg(long = "family-name")]
family_name: Option<String>,
#[arg(long = "nick-given-name")]
nick_given_name: Option<String>,
#[arg(long = "nick-family-name")]
nick_family_name: Option<String>,
#[arg(long)]
note: Option<String>,
},
UpdateDevice {
#[arg(short = 'd', long = "device-id")]
device_id: u32,
#[arg(short = 'n', long = "device-name")]
device_name: String,
},
UpdateGroup {
#[arg(short = 'g', long = "group-id")]
group_id: Option<String>,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(short = 'd', long)]
description: Option<String>,
#[arg(short = 'a', long)]
avatar: Option<String>,
#[arg(short = 'm', long)]
member: Vec<String>,
#[arg(short = 'r', long = "remove-member")]
remove_member: Vec<String>,
#[arg(long)]
admin: Vec<String>,
#[arg(long = "remove-admin")]
remove_admin: Vec<String>,
#[arg(long)]
ban: Vec<String>,
#[arg(long)]
unban: Vec<String>,
#[arg(long = "reset-link")]
reset_link: bool,
#[arg(value_enum, long)]
link: Option<LinkState>,
#[arg(value_enum, long = "set-permission-add-member")]
set_permission_add_member: Option<GroupPermission>,
#[arg(value_enum, long = "set-permission-edit-details")]
set_permission_edit_details: Option<GroupPermission>,
#[arg(value_enum, long = "set-permission-send-messages")]
set_permission_send_messages: Option<GroupPermission>,
#[arg(short = 'e', long)]
expiration: Option<u32>,
#[arg(long = "member-label-emoji")]
member_label_emoji: Option<String>,
#[arg(long = "member-label")]
member_label: Option<String>,
},
UpdateProfile {
#[arg(long = "given-name")]
given_name: Option<String>,
#[arg(long = "family-name")]
family_name: Option<String>,
#[arg(long)]
about: Option<String>,
#[arg(long = "about-emoji")]
about_emoji: Option<String>,
#[arg(long = "mobile-coin-address", visible_alias = "mobilecoin-address")]
mobile_coin_address: Option<String>,
#[arg(long)]
avatar: Option<String>,
#[arg(long = "remove-avatar")]
remove_avatar: bool,
},
UploadStickerPack {
path: String,
},
Verify {
verification_code: String,
#[arg(short = 'p', long)]
pin: Option<String>,
},
Version,
}
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum ReceiptType {
Read,
Viewed,
}
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum LinkState {
Enabled,
EnabledWithApproval,
Disabled,
}
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum GroupPermission {
EveryMember,
OnlyAdmins,
}
#[derive(ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab-case")]
pub enum MessageRequestResponseType {
Accept,
Delete,
}

View File

@ -1,532 +0,0 @@
use std::path::Path;
use jsonrpsee::async_client::ClientBuilder;
use jsonrpsee::core::client::{Error, SubscriptionClientT};
use jsonrpsee::http_client::HttpClientBuilder;
use jsonrpsee::proc_macros::rpc;
use serde::Deserialize;
use serde_json::Value;
use tokio::net::ToSocketAddrs;
#[rpc(client)]
pub trait Rpc {
#[method(name = "addDevice", param_kind = map)]
async fn add_device(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "addStickerPack", param_kind = map)]
async fn add_sticker_pack(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "block", param_kind = map)]
fn block(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "deleteLocalAccountData", param_kind = map)]
fn delete_local_account_data(
&self,
account: Option<String>,
#[allow(non_snake_case)] ignoreRegistered: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAttachment", param_kind = map)]
fn get_attachment(
&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)]
fn get_avatar(
&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)]
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(
&self,
#[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)]
fn list_accounts(&self) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listContacts", param_kind = map)]
fn list_contacts(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>,
name: Option<String>,
detailed: bool,
internal: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)]
fn list_devices(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listGroups", param_kind = map)]
fn list_groups(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listIdentities", param_kind = map)]
fn list_identities(
&self,
account: Option<String>,
number: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listStickerPacks", param_kind = map)]
fn list_sticker_packs(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "quitGroup", param_kind = map)]
fn quit_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: String,
delete: bool,
admins: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "register", param_kind = map)]
fn register(
&self,
account: Option<String>,
voice: bool,
captcha: Option<String>,
reregister: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)]
fn remove_contact(
&self,
account: Option<String>,
recipient: String,
forget: bool,
hide: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeDevice", param_kind = map)]
fn remove_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removePin", param_kind = map)]
fn remove_pin(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "remoteDelete", param_kind = map)]
fn remote_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] targetTimestamp: u64,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "send", param_kind = map)]
fn send(
&self,
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] notifySelf: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] endSession: bool,
message: String,
attachments: Vec<String>,
#[allow(non_snake_case)] viewOnce: bool,
mentions: Vec<String>,
#[allow(non_snake_case)] textStyle: Vec<String>,
#[allow(non_snake_case)] quoteTimestamp: Option<u64>,
#[allow(non_snake_case)] quoteAuthor: Option<String>,
#[allow(non_snake_case)] quoteMessage: Option<String>,
#[allow(non_snake_case)] quoteMention: Vec<String>,
#[allow(non_snake_case)] quoteTextStyle: Vec<String>,
#[allow(non_snake_case)] quoteAttachment: Vec<String>,
#[allow(non_snake_case)] previewUrl: Option<String>,
#[allow(non_snake_case)] previewTitle: Option<String>,
#[allow(non_snake_case)] previewDescription: Option<String>,
#[allow(non_snake_case)] previewImage: Option<String>,
sticker: Option<String>,
#[allow(non_snake_case)] storyTimestamp: Option<u64>,
#[allow(non_snake_case)] storyAuthor: Option<String>,
#[allow(non_snake_case)] editTimestamp: Option<u64>,
#[allow(non_snake_case)] noUrgent: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendAdminDelete", param_kind = map)]
fn send_admin_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
story: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPinMessage", param_kind = map)]
fn send_pin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] pinDuration: Option<i32>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollCreate", param_kind = map)]
fn send_poll_create(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
question: String,
option: Vec<String>,
#[allow(non_snake_case)] noMulti: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollVote", param_kind = map)]
fn send_poll_vote(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollAuthor: Option<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
option: Vec<i32>,
#[allow(non_snake_case)] voteCount: i32,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollTerminate", param_kind = map)]
fn send_poll_terminate(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendUnpinMessage", param_kind = map)]
fn send_unpin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification(
&self,
account: Option<String>,
recipient: String,
receipt: String,
note: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReaction", param_kind = map)]
fn send_reaction(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
emoji: String,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
remove: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReceipt", param_kind = map)]
fn send_receipt(
&self,
account: Option<String>,
recipient: String,
usernames: Vec<String>,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendSyncRequest", param_kind = map)]
fn send_sync_request(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendTyping", param_kind = map)]
fn send_typing(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
stop: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendMessageRequestResponse", param_kind = map)]
fn send_message_request_response(
&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)]
fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value, ErrorObjectOwned>;
#[method(name = "submitRateLimitChallenge", param_kind = map)]
fn submit_rate_limit_challenge(
&self,
account: Option<String>,
challenge: String,
captcha: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startChangeNumber", param_kind = map)]
fn start_change_number(
&self,
account: Option<String>,
number: String,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startLink", param_kind = map)]
fn start_link(&self, account: Option<String>) -> Result<JsonLink, ErrorObjectOwned>;
#[method(name = "trust", param_kind = map)]
fn trust(
&self,
account: Option<String>,
recipient: String,
#[allow(non_snake_case)] trustAllKnownKeys: bool,
#[allow(non_snake_case)] verifiedSafetyNumber: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unblock", param_kind = map)]
fn unblock(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unregister", param_kind = map)]
fn unregister(
&self,
account: Option<String>,
#[allow(non_snake_case)] deleteAccount: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "updateAccount", param_kind = map)]
fn update_account(
&self,
account: Option<String>,
deviceName: Option<String>,
unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
username: Option<String>,
deleteUsername: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)]
fn update_configuration(
&self,
account: Option<String>,
#[allow(non_snake_case)] readReceipts: Option<bool>,
#[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>,
#[allow(non_snake_case)] typingIndicators: Option<bool>,
#[allow(non_snake_case)] linkPreviews: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateContact", param_kind = map)]
fn update_contact(
&self,
account: Option<String>,
recipient: String,
name: Option<String>,
expiration: Option<u32>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
#[allow(non_snake_case)] nickGivenName: Option<String>,
#[allow(non_snake_case)] nickFamilyName: Option<String>,
note: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateDevice", param_kind = map)]
fn update_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]
fn update_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
name: Option<String>,
description: Option<String>,
avatar: Option<String>,
member: Vec<String>,
#[allow(non_snake_case)] removeMember: Vec<String>,
admin: Vec<String>,
#[allow(non_snake_case)] removeAdmin: Vec<String>,
ban: Vec<String>,
unban: Vec<String>,
#[allow(non_snake_case)] resetLink: bool,
#[allow(non_snake_case)] link: Option<String>,
#[allow(non_snake_case)] setPermissionAddMember: Option<String>,
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>,
#[allow(non_snake_case)] memberLabelEmoji: Option<String>,
#[allow(non_snake_case)] memberLabel: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)]
fn update_profile(
&self,
account: Option<String>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
about: Option<String>,
#[allow(non_snake_case)] aboutEmoji: Option<String>,
#[allow(non_snake_case)] mobileCoinAddress: Option<String>,
avatar: Option<String>,
#[allow(non_snake_case)] removeAvatar: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "uploadStickerPack", param_kind = map)]
fn upload_sticker_pack(
&self,
account: Option<String>,
path: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "verify", param_kind = map)]
fn verify(
&self,
account: Option<String>,
#[allow(non_snake_case)] verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[subscription(
name = "subscribeReceive" => "receive",
unsubscribe = "unsubscribeReceive",
item = Value,
param_kind = map
)]
async fn subscribe_receive(&self, account: Option<String>) -> SubscriptionResult;
#[method(name = "version")]
fn version(&self) -> Result<Value, ErrorObjectOwned>;
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonLink {
pub device_link_uri: String,
}
pub async fn connect_tcp(
tcp: impl ToSocketAddrs,
) -> 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<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,728 +0,0 @@
use std::{path::PathBuf, time::Duration};
use clap::Parser;
use jsonrpsee::core::client::{Error as RpcError, Subscription, SubscriptionClientT};
use serde_json::{Error, Value};
use tokio::{select, time::sleep};
use cli::Cli;
use crate::cli::{CliCommands, GroupPermission, LinkState};
use crate::jsonrpc::RpcClient;
mod cli;
#[allow(non_snake_case, clippy::too_many_arguments)]
mod jsonrpc;
mod transports;
const DEFAULT_TCP: &str = "127.0.0.1:7583";
const DEFAULT_SOCKET_SUFFIX: &str = "signal-cli/socket";
const DEFAULT_HTTP: &str = "http://localhost:8080/api/v1/rpc";
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let cli = cli::Cli::parse();
let result = connect(cli).await;
match result {
Ok(Value::Null) => {}
Ok(v) => println!("{v}"),
Err(e) => return Err(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 {
let v = v?;
println!("{v}");
}
}
stream.unsubscribe().await?;
Ok(Value::Null)
}
CliCommands::AddDevice { uri } => client.add_device(cli.account, uri).await,
CliCommands::Block {
recipient,
group_id,
} => client.block(cli.account, recipient, group_id).await,
CliCommands::DeleteLocalAccountData { ignore_registered } => {
client
.delete_local_account_data(cli.account, ignore_registered)
.await
}
CliCommands::GetUserStatus {
recipient,
username,
} => {
client
.get_user_status(cli.account, recipient, username)
.await
}
CliCommands::JoinGroup { uri } => client.join_group(cli.account, uri).await,
CliCommands::Link { name } => {
let url = client
.start_link(cli.account)
.await
.map_err(|e| RpcError::Custom(format!("JSON-RPC command startLink failed: {e:?}")))?
.device_link_uri;
println!("{url}");
client.finish_link(url, name).await
}
CliCommands::ListAccounts => client.list_accounts().await,
CliCommands::ListContacts {
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
} => {
client
.list_contacts(
cli.account,
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
)
.await
}
CliCommands::ListDevices => client.list_devices(cli.account).await,
CliCommands::ListGroups {
detailed: _,
group_id,
} => client.list_groups(cli.account, group_id).await,
CliCommands::ListIdentities { number } => client.list_identities(cli.account, number).await,
CliCommands::ListStickerPacks => client.list_sticker_packs(cli.account).await,
CliCommands::QuitGroup {
group_id,
delete,
admin,
} => {
client
.quit_group(cli.account, group_id, delete, admin)
.await
}
CliCommands::Register {
voice,
captcha,
reregister,
} => {
client
.register(cli.account, voice, captcha, reregister)
.await
}
CliCommands::RemoveContact {
recipient,
forget,
hide,
} => {
client
.remove_contact(cli.account, recipient, forget, hide)
.await
}
CliCommands::RemoveDevice { device_id } => {
client.remove_device(cli.account, device_id).await
}
CliCommands::RemovePin => client.remove_pin(cli.account).await,
CliCommands::RemoteDelete {
target_timestamp,
recipient,
group_id,
note_to_self,
} => {
client
.remote_delete(
cli.account,
target_timestamp,
recipient,
group_id,
note_to_self,
)
.await
}
CliCommands::Send {
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
message,
message_from_stdin,
attachment,
view_once,
mention,
text_style,
quote_timestamp,
quote_author,
quote_message,
quote_mention,
quote_text_style,
quote_attachment,
preview_url,
preview_title,
preview_description,
preview_image,
sticker,
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
} => {
client
.send(
cli.account,
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
if message_from_stdin {
std::io::read_to_string(std::io::stdin()).unwrap()
} else {
message.unwrap_or_default()
},
attachment,
view_once,
mention,
text_style,
quote_timestamp,
quote_author,
quote_message,
quote_mention,
quote_text_style,
quote_attachment,
preview_url,
preview_title,
preview_description,
preview_image,
sticker,
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
)
.await
}
CliCommands::SendContacts => client.send_contacts(cli.account).await,
CliCommands::SendAdminDelete {
group_id,
target_author,
target_timestamp,
story,
notify_self,
} => {
client
.send_admin_delete(
cli.account,
group_id,
target_author,
target_timestamp,
story,
notify_self,
)
.await
}
CliCommands::SendPaymentNotification {
recipient,
receipt,
note,
} => {
client
.send_payment_notification(cli.account, recipient, receipt, note)
.await
}
CliCommands::SendPinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
} => {
client
.send_pin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SendPollCreate {
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
} => {
client
.send_poll_create(
cli.account,
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollTerminate {
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
} => {
client
.send_poll_terminate(
cli.account,
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollVote {
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
} => {
client
.send_poll_vote(
cli.account,
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendReaction {
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
remove,
story,
} => {
client
.send_reaction(
cli.account,
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
remove,
story,
)
.await
}
CliCommands::SendReceipt {
recipient,
username,
target_timestamp,
r#type,
} => {
client
.send_receipt(
cli.account,
recipient,
username,
target_timestamp,
match r#type {
cli::ReceiptType::Read => "read".to_owned(),
cli::ReceiptType::Viewed => "viewed".to_owned(),
},
)
.await
}
CliCommands::SendSyncRequest => client.send_sync_request(cli.account).await,
CliCommands::SendTyping {
recipient,
group_id,
stop,
} => {
client
.send_typing(cli.account, recipient, group_id, stop)
.await
}
CliCommands::SendUnpinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
} => {
client
.send_unpin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SetPin { pin } => client.set_pin(cli.account, pin).await,
CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
client
.submit_rate_limit_challenge(cli.account, challenge, captcha)
.await
}
CliCommands::Trust {
recipient,
trust_all_known_keys,
verified_safety_number,
} => {
client
.trust(
cli.account,
recipient,
trust_all_known_keys,
verified_safety_number,
)
.await
}
CliCommands::Unblock {
recipient,
group_id,
} => client.unblock(cli.account, recipient, group_id).await,
CliCommands::Unregister { delete_account } => {
client.unregister(cli.account, delete_account).await
}
CliCommands::UpdateAccount {
device_name,
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 {
read_receipts,
unidentified_delivery_indicators,
typing_indicators,
link_previews,
} => {
client
.update_configuration(
cli.account,
read_receipts,
unidentified_delivery_indicators,
typing_indicators,
link_previews,
)
.await
}
CliCommands::UpdateContact {
recipient,
expiration,
name,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
} => {
client
.update_contact(
cli.account,
recipient,
name,
expiration,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
)
.await
}
CliCommands::UpdateDevice {
device_id,
device_name,
} => {
client
.update_device(cli.account, device_id, device_name)
.await
}
CliCommands::UpdateGroup {
group_id,
name,
description,
avatar,
member,
remove_member,
admin,
remove_admin,
ban,
unban,
reset_link,
link,
set_permission_add_member,
set_permission_edit_details,
set_permission_send_messages,
expiration,
member_label_emoji,
member_label,
} => {
client
.update_group(
cli.account,
group_id,
name,
description,
avatar,
member,
remove_member,
admin,
remove_admin,
ban,
unban,
reset_link,
link.map(|link| match link {
LinkState::Enabled => "enabled".to_owned(),
LinkState::EnabledWithApproval => "enabledWithApproval".to_owned(),
LinkState::Disabled => "disabled".to_owned(),
}),
set_permission_add_member.map(|p| match p {
GroupPermission::EveryMember => "everyMember".to_owned(),
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}),
set_permission_edit_details.map(|p| match p {
GroupPermission::EveryMember => "everyMember".to_owned(),
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}),
set_permission_send_messages.map(|p| match p {
GroupPermission::EveryMember => "everyMember".to_owned(),
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}),
expiration,
member_label_emoji,
member_label,
)
.await
}
CliCommands::UpdateProfile {
given_name,
family_name,
about,
about_emoji,
mobile_coin_address,
avatar,
remove_avatar,
} => {
client
.update_profile(
cli.account,
given_name,
family_name,
about,
about_emoji,
mobile_coin_address,
avatar,
remove_avatar,
)
.await
}
CliCommands::UploadStickerPack { path } => {
client.upload_sticker_pack(cli.account, path).await
}
CliCommands::Verify {
verification_code,
pin,
} => client.verify(cli.account, verification_code, pin).await,
CliCommands::Version => client.version().await,
CliCommands::AddStickerPack { uri } => client.add_sticker_pack(cli.account, uri).await,
CliCommands::FinishChangeNumber {
number,
verification_code,
pin,
} => {
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> {
if let Some(http) = &cli.json_rpc_http {
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 client = jsonrpc::connect_tcp(socket_addr)
.await
.map_err(|e| RpcError::Custom(format!("Failed to connect to socket: {e}")))?;
handle_command(cli, client).await
} else {
#[cfg(windows)]
{
Err(RpcError::Custom("Invalid socket".into()))
}
#[cfg(unix)]
{
let socket_path = cli
.json_rpc_socket
.clone()
.unwrap_or(None)
.or_else(|| {
std::env::var_os("XDG_RUNTIME_DIR").map(|runtime_dir| {
PathBuf::from(runtime_dir)
.join(DEFAULT_SOCKET_SUFFIX)
.into()
})
})
.unwrap_or_else(|| ("/run".to_owned() + DEFAULT_SOCKET_SUFFIX).into());
let client = jsonrpc::connect_unix(socket_path)
.await
.map_err(|e| RpcError::Custom(format!("Failed to connect to socket: {e}")))?;
handle_command(cli, client).await
}
}
}
async fn stream_next(
timeout: f64,
stream: &mut Subscription<Value>,
) -> Option<Result<Value, Error>> {
if timeout < 0.0 {
stream.next().await
} else {
select! {
v = stream.next() => v,
_= sleep(Duration::from_millis((timeout * 1000.0) as u64)) => None,
}
}
}

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

@ -1,46 +0,0 @@
[Unit]
Description=Send secure messages to Signal clients
Wants=network-online.target
After=network-online.target
Requires=signal-cli-socket.socket
[Service]
CapabilityBoundingSet=
Environment="SIGNAL_CLI_OPTS=-Xms2m"
# Update 'ReadWritePaths' if you change the config path here
ExecStart=%dir%/bin/signal-cli --config /var/lib/signal-cli daemon
LockPersonality=true
NoNewPrivileges=true
PrivateDevices=true
PrivateIPC=true
PrivateTmp=true
PrivateUsers=true
ProcSubset=pid
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=invisible
ProtectSystem=strict
# Profile pictures and attachments to upload must be located here for the service to access them
ReadWritePaths=/var/lib/signal-cli
RemoveIPC=true
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
StandardInput=socket
StandardOutput=journal
StandardError=journal
SystemCallArchitectures=native
SystemCallFilter=~@debug @mount @obsolete @privileged @resources
UMask=0077
# Create the user and home directory with 'useradd -r -U -s /usr/sbin/nologin -m -b /var/lib signal-cli'
User=signal-cli
[Install]
Also=signal-cli-socket.socket
WantedBy=default.target

View File

@ -1,13 +0,0 @@
[Unit]
Description=Send secure messages to Signal clients
[Socket]
ListenStream=%t/signal-cli/socket
SocketUser=root
# Add yourself to the signal-cli group to talk with the service
# Run 'usermod -aG signal-cli yourusername'
SocketGroup=signal-cli
SocketMode=0660
[Install]
WantedBy=sockets.target

View File

@ -8,7 +8,7 @@ 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

View File

@ -1 +0,0 @@
u signal-cli - "Signal messaging service" /var/lib/signal-cli

View File

@ -1,5 +0,0 @@
d /var/lib/signal-cli 0755 signal-cli signal-cli -
d /var/lib/signal-cli/data 0700 signal-cli signal-cli -
d /var/lib/signal-cli/attachments 0750 signal-cli signal-cli -
d /var/lib/signal-cli/avatars 0750 signal-cli signal-cli -
d /var/lib/signal-cli/stickers 0750 signal-cli signal-cli -

View File

@ -8,7 +8,7 @@ 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 -u %I --config /var/lib/signal-cli daemon --system
User=signal-cli User=signal-cli
BusName=org.asamk.Signal BusName=org.asamk.Signal

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,97 @@
[
{
"name":"java.lang.ClassLoader",
"methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }]
},
{
"name":"java.lang.IllegalStateException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.NoSuchMethodError"
},
{
"name":"java.lang.UnsatisfiedLinkError",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore",
"methods":[
{"name":"getIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
{"name":"getIdentityKeyPair","parameterTypes":[] },
{"name":"getLocalRegistrationId","parameterTypes":[] },
{"name":"isTrustedIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey","org.whispersystems.libsignal.state.IdentityKeyStore$Direction"] },
{"name":"loadPreKey","parameterTypes":["int"] },
{"name":"loadSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress"] },
{"name":"loadSignedPreKey","parameterTypes":["int"] },
{"name":"removePreKey","parameterTypes":["int"] },
{"name":"saveIdentity","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.IdentityKey"] },
{"name":"storeSession","parameterTypes":["org.whispersystems.libsignal.SignalProtocolAddress","org.whispersystems.libsignal.state.SessionRecord"] }
]
},
{
"name":"org.whispersystems.libsignal.DuplicateMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.whispersystems.libsignal.IdentityKey",
"methods":[
{"name":"<init>","parameterTypes":["byte[]"] },
{"name":"serialize","parameterTypes":[] }
]
},
{
"name":"org.whispersystems.libsignal.IdentityKeyPair",
"methods":[{"name":"serialize","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.InvalidMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.whispersystems.libsignal.SignalProtocolAddress",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","int"] }]
},
{
"name":"org.whispersystems.libsignal.protocol.PreKeySignalMessage",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.whispersystems.libsignal.protocol.SignalMessage",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.whispersystems.libsignal.state.IdentityKeyStore"
},
{
"name":"org.whispersystems.libsignal.state.IdentityKeyStore$Direction",
"fields":[
{"name":"RECEIVING"},
{"name":"SENDING"}
]
},
{
"name":"org.whispersystems.libsignal.state.PreKeyRecord",
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.state.PreKeyStore"
},
{
"name":"org.whispersystems.libsignal.state.SessionRecord",
"methods":[
{"name":"<init>","parameterTypes":["byte[]"] },
{"name":"nativeHandle","parameterTypes":[] }
]
},
{
"name":"org.whispersystems.libsignal.state.SessionStore"
},
{
"name":"org.whispersystems.libsignal.state.SignedPreKeyRecord",
"methods":[{"name":"nativeHandle","parameterTypes":[] }]
},
{
"name":"org.whispersystems.libsignal.state.SignedPreKeyStore"
}
]

View File

@ -0,0 +1,3 @@
[
["org.freedesktop.DBus"]
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
{
"resources":{
"includes":[
{"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE\\E"},
{"pattern":"\\Qlibsignal_jni.so\\E"},
{"pattern":"\\Qlibzkgroup.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":"com/google/i18n/phonenumbers/data/.*"}
]},
"bundles":[{"name":"net.sourceforge.argparse4j.internal.ArgumentParserImpl"}]
}

View File

@ -0,0 +1,2 @@
[
]

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,5 @@
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-6.8-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

283
gradlew vendored
View File

@ -1,7 +1,7 @@
#!/bin/sh #!/usr/bin/env sh
# #
# Copyright © 2015 the original authors. # Copyright 2015 the original author or 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,114 +15,81 @@
# 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 UN*X
# ##
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (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
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
############################################################################## ##############################################################################
# Attempt to set APP_HOME # Attempt to set APP_HOME
# Resolve links: $0 may be a link # Resolve links: $0 may be a link
app_path=$0 PRG="$0"
# Need this for relative symlinks.
# Need this for daisy-chained symlinks. while [ -h "$PRG" ] ; do
while ls=`ls -ld "$PRG"`
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path link=`expr "$ls" : '.*-> \(.*\)$'`
[ -h "$app_path" ] if expr "$link" : '/.*' > /dev/null; then
do PRG="$link"
ls=$( ls -ld "$app_path" ) else
link=${ls#*' -> '} PRG=`dirname "$PRG"`"/$link"
case $link in #( fi
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
# This is normally unused APP_NAME="Gradle"
# shellcheck disable=SC2034 APP_BASE_NAME=`basename "$0"`
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 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"
warn () { warn () {
echo "$*" echo "$*"
} >&2 }
die () { die () {
echo echo
echo "$*" echo "$*"
echo echo
exit 1 exit 1
} >&2 }
# OS specific support (must be 'true' or 'false'). # OS specific support (must be 'true' or 'false').
cygwin=false cygwin=false
msys=false msys=false
darwin=false darwin=false
nonstop=false nonstop=false
case "$( uname )" in #( case "`uname`" in
CYGWIN* ) cygwin=true ;; #( CYGWIN* )
Darwin* ) darwin=true ;; #( cygwin=true
MSYS* | MINGW* ) msys=true ;; #( ;;
NONSTOP* ) nonstop=true ;; Darwin* )
darwin=true
;;
MINGW* )
msys=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.
if [ -n "$JAVA_HOME" ] ; then if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables # IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java JAVACMD="$JAVA_HOME/jre/sh/java"
else else
JAVACMD=$JAVA_HOME/bin/java JAVACMD="$JAVA_HOME/bin/java"
fi fi
if [ ! -x "$JAVACMD" ] ; then if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -131,118 +98,88 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." 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.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
case $MAX_FD in #( MAX_FD_LIMIT=`ulimit -H -n`
max*) if [ $? -eq 0 ] ; then
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
# shellcheck disable=SC2039,SC3045 MAX_FD="$MAX_FD_LIMIT"
MAX_FD=$( ulimit -H -n ) || fi
warn "Could not query maximum file descriptor limit" ulimit -n $MAX_FD
esac if [ $? -ne 0 ] ; then
case $MAX_FD in #( warn "Could not set maximum file descriptor limit: $MAX_FD"
'' | soft) :;; #( fi
*) else
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
# shellcheck disable=SC2039,SC3045 fi
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi fi
# Collect all arguments for the java command, stacking in reverse order: # For Darwin, add options to specify how the application appears in the dock
# * args from the command line if $darwin; then
# * the main class name GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
# * -classpath fi
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# 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" = "true" -o "$msys" = "true" ] ; 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"`
# Now convert the arguments - kludge to limit ourselves to /bin/sh # We build the pattern for arguments to be converted via cygpath
for arg do ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
if SEP=""
case $arg in #( for dir in $ROOTDIRSRAW ; do
-*) false ;; # don't mess with options #( ROOTDIRS="$ROOTDIRS$SEP$dir"
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath SEP="|"
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Collect all arguments for the java command, following the shell quoting and substitution rules
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@" exec "$JAVACMD" "$@"

59
gradlew.bat vendored
View File

@ -13,22 +13,19 @@
@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=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@ -43,15 +40,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe 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%" == "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 +56,34 @@ 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%"=="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!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -4,41 +4,20 @@ plugins {
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_25 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_11
if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) {
toolchain {
languageVersion.set(JavaLanguageVersion.of(targetCompatibility.majorVersion))
}
}
} }
val libsignalClientPath = project.findProperty("libsignal_client_path")?.toString() repositories {
mavenLocal()
mavenCentral()
}
dependencies { dependencies {
if (libsignalClientPath == null) { api("com.github.turasa:signal-service-java:2.15.3_unofficial_19")
implementation(libs.signalnetwork) implementation("com.google.protobuf:protobuf-javalite:3.10.0")
} else { implementation("org.bouncycastle:bcprov-jdk15on:1.68")
implementation(libs.signalnetwork) { implementation("org.slf4j:slf4j-api:1.7.30")
exclude(group = "org.signal", module = "libsignal-client")
}
implementation(files(libsignalClientPath))
}
implementation(libs.jackson.databind)
implementation(libs.bouncycastle)
implementation(libs.slf4j.api)
implementation(libs.sqlite)
implementation(libs.hikari)
compileOnly(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
} }
configurations { configurations {
@ -47,17 +26,6 @@ configurations {
} }
} }
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
tasks.withType<JavaCompile> { tasks.withType<JavaCompile> {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
tasks.jar {
manifest {
attributes("Automatic-Module-Name" to "org.asamk.signal.manager")
}
}

View File

@ -1,4 +1,4 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager;
public class AttachmentInvalidException extends Exception { public class AttachmentInvalidException extends Exception {

View File

@ -0,0 +1,55 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.util.IOUtils;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public class AttachmentStore {
private final File attachmentsPath;
public AttachmentStore(final File attachmentsPath) {
this.attachmentsPath = attachmentsPath;
}
public void storeAttachmentPreview(
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
) throws IOException {
storeAttachment(getAttachmentPreviewFile(attachmentId), storer);
}
public void storeAttachment(
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
) throws IOException {
storeAttachment(getAttachmentFile(attachmentId), storer);
}
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
createAttachmentsDir();
try (OutputStream output = new FileOutputStream(attachmentFile)) {
storer.store(output);
}
}
private File getAttachmentPreviewFile(SignalServiceAttachmentRemoteId attachmentId) {
return new File(attachmentsPath, attachmentId.toString() + ".preview");
}
public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) {
return new File(attachmentsPath, attachmentId.toString());
}
private void createAttachmentsDir() throws IOException {
IOUtils.createPrivateDirectories(attachmentsPath);
}
@FunctionalInterface
public interface AttachmentStorer {
void store(OutputStream outputStream) throws IOException;
}
}

View File

@ -1,9 +1,9 @@
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.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.File; import java.io.File;
@ -20,24 +20,24 @@ public class AvatarStore {
this.avatarsPath = avatarsPath; this.avatarsPath = avatarsPath;
} }
public StreamDetails retrieveContactAvatar(RecipientAddress address) throws IOException { public StreamDetails retrieveContactAvatar(SignalServiceAddress address) throws IOException {
return retrieveAvatar(getContactAvatarFile(address)); return retrieveAvatar(getContactAvatarFile(address));
} }
public StreamDetails retrieveProfileAvatar(RecipientAddress address) throws IOException { public StreamDetails retrieveProfileAvatar(SignalServiceAddress address) throws IOException {
return retrieveAvatar(getProfileAvatarFile(address)); return retrieveAvatar(getProfileAvatarFile(address));
} }
public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException { public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException {
final var groupAvatarFile = getGroupAvatarFile(groupId); final File groupAvatarFile = getGroupAvatarFile(groupId);
return retrieveAvatar(groupAvatarFile); return retrieveAvatar(groupAvatarFile);
} }
public void storeContactAvatar(RecipientAddress address, AvatarStorer storer) throws IOException { public void storeContactAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
storeAvatar(getContactAvatarFile(address), storer); storeAvatar(getContactAvatarFile(address), storer);
} }
public void storeProfileAvatar(RecipientAddress address, AvatarStorer storer) throws IOException { public void storeProfileAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
storeAvatar(getProfileAvatarFile(address), storer); storeAvatar(getProfileAvatarFile(address), storer);
} }
@ -45,14 +45,10 @@ public class AvatarStore {
storeAvatar(getGroupAvatarFile(groupId), storer); storeAvatar(getGroupAvatarFile(groupId), storer);
} }
public void deleteProfileAvatar(RecipientAddress address) throws IOException { public void deleteProfileAvatar(SignalServiceAddress address) throws IOException {
deleteAvatar(getProfileAvatarFile(address)); deleteAvatar(getProfileAvatarFile(address));
} }
public void deleteGroupAvatar(GroupId groupId) throws IOException {
deleteAvatar(getGroupAvatarFile(groupId));
}
private StreamDetails retrieveAvatar(final File avatarFile) throws IOException { private StreamDetails retrieveAvatar(final File avatarFile) throws IOException {
if (!avatarFile.exists()) { if (!avatarFile.exists()) {
return null; return null;
@ -77,11 +73,11 @@ public class AvatarStore {
return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_")); return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_"));
} }
private File getContactAvatarFile(RecipientAddress address) { private File getContactAvatarFile(SignalServiceAddress address) {
return new File(avatarsPath, "contact-" + address.getLegacyIdentifier()); return new File(avatarsPath, "contact-" + address.getLegacyIdentifier());
} }
private File getProfileAvatarFile(RecipientAddress address) { private File getProfileAvatarFile(SignalServiceAddress address) {
return new File(avatarsPath, "profile-" + address.getLegacyIdentifier()); return new File(avatarsPath, "profile-" + address.getLegacyIdentifier());
} }

View File

@ -0,0 +1,71 @@
package org.asamk.signal.manager;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class DeviceLinkInfo {
final String deviceIdentifier;
final ECPublicKey deviceKey;
public static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws InvalidKeyException {
final String rawQuery = linkUri.getRawQuery();
if (isEmpty(rawQuery)) {
throw new RuntimeException("Invalid device link uri");
}
Map<String, String> query = getQueryMap(rawQuery);
String deviceIdentifier = query.get("uuid");
String publicKeyEncoded = query.get("pub_key");
if (isEmpty(deviceIdentifier) || isEmpty(publicKeyEncoded)) {
throw new RuntimeException("Invalid device link uri");
}
final byte[] publicKeyBytes;
try {
publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);
} catch (IllegalArgumentException e) {
throw new RuntimeException("Invalid device link uri", e);
}
ECPublicKey deviceKey = Curve.decodePoint(publicKeyBytes, 0);
return new DeviceLinkInfo(deviceIdentifier, deviceKey);
}
private static Map<String, String> getQueryMap(String query) {
String[] params = query.split("&");
Map<String, String> map = new HashMap<>();
for (String param : params) {
final String[] paramParts = param.split("=");
String name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
String value = URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
map.put(name, value);
}
return map;
}
public DeviceLinkInfo(final String deviceIdentifier, final ECPublicKey deviceKey) {
this.deviceIdentifier = deviceIdentifier;
this.deviceKey = deviceKey;
}
public String createDeviceLinkUri() {
final String deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
return "tsdevice:/?uuid="
+ URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
+ "&pub_key="
+ URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,159 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.groups.GroupIdV1;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Objects;
interface HandleAction {
void execute(Manager m) throws Throwable;
}
class SendReceiptAction implements HandleAction {
private final SignalServiceAddress address;
private final long timestamp;
public SendReceiptAction(final SignalServiceAddress address, final long timestamp) {
this.address = address;
this.timestamp = timestamp;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendReceipt(address, timestamp);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendReceiptAction that = (SendReceiptAction) o;
return timestamp == that.timestamp && address.equals(that.address);
}
@Override
public int hashCode() {
return Objects.hash(address, timestamp);
}
}
class SendSyncContactsAction implements HandleAction {
private static final SendSyncContactsAction INSTANCE = new SendSyncContactsAction();
private SendSyncContactsAction() {
}
public static SendSyncContactsAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendContacts();
}
}
class SendSyncGroupsAction implements HandleAction {
private static final SendSyncGroupsAction INSTANCE = new SendSyncGroupsAction();
private SendSyncGroupsAction() {
}
public static SendSyncGroupsAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroups();
}
}
class SendSyncBlockedListAction implements HandleAction {
private static final SendSyncBlockedListAction INSTANCE = new SendSyncBlockedListAction();
private SendSyncBlockedListAction() {
}
public static SendSyncBlockedListAction create() {
return INSTANCE;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendBlockedList();
}
}
class SendGroupInfoRequestAction implements HandleAction {
private final SignalServiceAddress address;
private final GroupIdV1 groupId;
public SendGroupInfoRequestAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
this.address = address;
this.groupId = groupId;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroupInfoRequest(groupId, address);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendGroupInfoRequestAction that = (SendGroupInfoRequestAction) o;
if (!address.equals(that.address)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
int result = address.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}
class SendGroupInfoAction implements HandleAction {
private final SignalServiceAddress address;
private final GroupIdV1 groupId;
public SendGroupInfoAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
this.address = address;
this.groupId = groupId;
}
@Override
public void execute(Manager m) throws Throwable {
m.sendGroupInfoMessage(groupId, address);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendGroupInfoAction that = (SendGroupInfoAction) o;
if (!address.equals(that.address)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
int result = address.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}

View File

@ -0,0 +1,29 @@
package org.asamk.signal.manager;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class JsonStickerPack {
@JsonProperty
public String title;
@JsonProperty
public String author;
@JsonProperty
public JsonSticker cover;
@JsonProperty
public List<JsonSticker> stickers;
public static class JsonSticker {
@JsonProperty
public String emoji;
@JsonProperty
public String file;
}
}

View File

@ -0,0 +1,41 @@
package org.asamk.signal.manager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.logging.SignalProtocolLogger;
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
public class LibSignalLogger implements SignalProtocolLogger {
private final static Logger logger = LoggerFactory.getLogger("LibSignal");
public static void initLogger() {
SignalProtocolLoggerProvider.setProvider(new LibSignalLogger());
}
private LibSignalLogger() {
}
@Override
public void log(final int priority, final String tag, final String message) {
final String logMessage = String.format("[%s]: %s", tag, message);
switch (priority) {
case SignalProtocolLogger.VERBOSE:
logger.trace(logMessage);
break;
case SignalProtocolLogger.DEBUG:
logger.debug(logMessage);
break;
case SignalProtocolLogger.INFO:
logger.info(logMessage);
break;
case SignalProtocolLogger.WARN:
logger.warn(logMessage);
break;
case SignalProtocolLogger.ERROR:
case SignalProtocolLogger.ASSERT:
logger.error(logMessage);
break;
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,29 +0,0 @@
package org.asamk.signal.manager;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
public interface MultiAccountManager extends AutoCloseable {
List<String> getAccountNumbers();
List<Manager> getManagers();
void addOnManagerAddedHandler(Consumer<Manager> handler);
void addOnManagerRemovedHandler(Consumer<Manager> handler);
Manager getManager(String phoneNumber);
URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException;
ProvisioningManager getProvisioningManagerFor(URI deviceLinkUri);
RegistrationManager getNewRegistrationManager(String account) throws IOException;
@Override
void close();
}

View File

@ -1,4 +1,4 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager;
public class NotRegisteredException extends Exception { public class NotRegisteredException extends Exception {

View File

@ -0,0 +1,34 @@
package org.asamk.signal.manager;
import java.io.File;
public class PathConfig {
private final File dataPath;
private final File attachmentsPath;
private final File avatarsPath;
public static PathConfig createDefault(final File settingsPath) {
return new PathConfig(new File(settingsPath, "data"),
new File(settingsPath, "attachments"),
new File(settingsPath, "avatars"));
}
private PathConfig(final File dataPath, final File attachmentsPath, final File avatarsPath) {
this.dataPath = dataPath;
this.attachmentsPath = attachmentsPath;
this.avatarsPath = avatarsPath;
}
public File getDataPath() {
return dataPath;
}
public File getAttachmentsPath() {
return attachmentsPath;
}
public File getAvatarsPath() {
return avatarsPath;
}
}

View File

@ -1,14 +1,162 @@
/*
Copyright (C) 2015-2021 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; 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.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.util.KeyHelper;
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.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
public interface ProvisioningManager { public class ProvisioningManager {
URI getDeviceLinkUri() throws TimeoutException, IOException; private final static Logger logger = LoggerFactory.getLogger(ProvisioningManager.class);
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException; private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final SignalServiceAccountManager accountManager;
private final IdentityKeyPair identityKey;
private final int registrationId;
private final String password;
ProvisioningManager(PathConfig pathConfig, ServiceEnvironmentConfig serviceEnvironmentConfig, String userAgent) {
this.pathConfig = pathConfig;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
identityKey = KeyUtils.generateIdentityKeyPair();
registrationId = KeyHelper.generateRegistrationId(false);
password = KeyUtils.createPassword();
final SleepTimer timer = new UptimeSleepTimer();
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
} catch (Throwable ignored) {
groupsV2Operations = null;
}
accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(null, null, password, SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
timer);
}
public static ProvisioningManager init(
File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
) {
PathConfig pathConfig = PathConfig.createDefault(settingsPath);
final ServiceEnvironmentConfig serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(
serviceEnvironment,
userAgent);
return new ProvisioningManager(pathConfig, serviceConfiguration, userAgent);
}
public String getDeviceLinkUri() throws TimeoutException, IOException {
String deviceUuid = accountManager.getNewDeviceUuid();
return new DeviceLinkInfo(deviceUuid, identityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
}
public String finishDeviceLink(String deviceName) throws IOException, InvalidKeyException, TimeoutException, UserAlreadyExists {
SignalServiceAccountManager.NewDeviceRegistrationReturn ret = accountManager.finishNewDeviceRegistration(
identityKey,
false,
true,
registrationId,
deviceName);
String username = ret.getNumber();
// TODO do this check before actually registering
if (SignalAccount.userExists(pathConfig.getDataPath(), username)) {
throw new UserAlreadyExists(username, SignalAccount.getFileName(pathConfig.getDataPath(), username));
}
// Create new account with the synced identity
byte[] profileKeyBytes = ret.getProfileKey();
ProfileKey profileKey;
if (profileKeyBytes == null) {
profileKey = KeyUtils.createProfileKey();
} else {
try {
profileKey = new ProfileKey(profileKeyBytes);
} catch (InvalidInputException e) {
throw new IOException("Received invalid profileKey", e);
}
}
try (SignalAccount account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(),
username,
ret.getUuid(),
password,
ret.getDeviceId(),
ret.getIdentity(),
registrationId,
profileKey)) {
account.save();
try (Manager m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent)) {
try {
m.refreshPreKeys();
} catch (Exception e) {
logger.error("Failed to refresh prekeys.");
throw e;
}
try {
m.requestSyncGroups();
m.requestSyncContacts();
m.requestSyncBlocked();
m.requestSyncConfiguration();
m.requestSyncKeys();
} catch (Exception e) {
logger.error("Failed to request sync messages from linked device.");
throw e;
}
m.close(false);
}
account.save();
}
return username;
}
} }

View File

@ -1,30 +1,210 @@
/*
Copyright (C) 2015-2021 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; package org.asamk.signal.manager;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.config.ServiceEnvironment;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException; import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.api.RateLimitException; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException; import org.signal.zkgroup.profiles.ProfileKey;
import org.whispersystems.libsignal.IdentityKeyPair;
import org.whispersystems.libsignal.util.KeyHelper;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.KbsPinData;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
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.util.SleepTimer;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.LockedException;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import java.io.Closeable; import java.io.Closeable;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Locale;
public interface RegistrationManager extends Closeable { public class RegistrationManager implements Closeable {
void register( private SignalAccount account;
boolean voiceVerification, private final PathConfig pathConfig;
String captcha, private final ServiceEnvironmentConfig serviceEnvironmentConfig;
final boolean forceRegister private final String userAgent;
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
void verifyAccount( private final SignalServiceAccountManager accountManager;
String verificationCode, private final PinHelper pinHelper;
String pin
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
void deleteLocalAccountData() throws IOException; public RegistrationManager(
SignalAccount account,
PathConfig pathConfig,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent
) {
this.account = account;
this.pathConfig = pathConfig;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
this.userAgent = userAgent;
boolean isRegistered(); final SleepTimer timer = new UptimeSleepTimer();
GroupsV2Operations groupsV2Operations;
try {
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()));
} catch (Throwable ignored) {
groupsV2Operations = null;
}
this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(),
new DynamicCredentialsProvider(
// Using empty UUID, because registering doesn't work otherwise
null, account.getUsername(), account.getPassword(), SignalServiceAddress.DEFAULT_DEVICE_ID),
userAgent,
groupsV2Operations,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
timer);
final KeyBackupService keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(),
serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(),
serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(),
serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(),
10);
this.pinHelper = new PinHelper(keyBackupService);
}
public static RegistrationManager init(
String username, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent
) throws IOException {
PathConfig pathConfig = PathConfig.createDefault(settingsPath);
final ServiceEnvironmentConfig serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(
serviceEnvironment,
userAgent);
if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) {
IdentityKeyPair identityKey = KeyUtils.generateIdentityKeyPair();
int registrationId = KeyHelper.generateRegistrationId(false);
ProfileKey profileKey = KeyUtils.createProfileKey();
SignalAccount account = SignalAccount.create(pathConfig.getDataPath(),
username,
identityKey,
registrationId,
profileKey);
account.save();
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
}
SignalAccount account = SignalAccount.load(pathConfig.getDataPath(), username);
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
}
public void register(boolean voiceVerification, String captcha) throws IOException {
if (account.getPassword() == null) {
account.setPassword(KeyUtils.createPassword());
}
if (voiceVerification) {
accountManager.requestVoiceVerificationCode(Locale.getDefault(),
Optional.fromNullable(captcha),
Optional.absent());
} else {
accountManager.requestSmsVerificationCode(false, Optional.fromNullable(captcha), Optional.absent());
}
account.save();
}
public void verifyAccount(
String verificationCode, String pin
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
verificationCode = verificationCode.replace("-", "");
VerifyAccountResponse response;
try {
response = verifyAccountWithCode(verificationCode, pin, null);
account.setPinMasterKey(null);
} catch (LockedException e) {
if (pin == null) {
throw e;
}
KbsPinData registrationLockData = pinHelper.getRegistrationLockData(pin, e);
if (registrationLockData == null) {
throw e;
}
String registrationLock = registrationLockData.getMasterKey().deriveRegistrationLock();
try {
response = verifyAccountWithCode(verificationCode, null, registrationLock);
} catch (LockedException _e) {
throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!");
}
account.setPinMasterKey(registrationLockData.getMasterKey());
}
// TODO response.isStorageCapable()
//accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
account.setDeviceId(SignalServiceAddress.DEFAULT_DEVICE_ID);
account.setMultiDevice(false);
account.setRegistered(true);
account.setUuid(UuidUtil.parseOrNull(response.getUuid()));
account.setRegistrationLockPin(pin);
account.getSignalProtocolStore().archiveAllSessions();
account.getSignalProtocolStore()
.saveIdentity(account.getSelfAddress(),
account.getSignalProtocolStore().getIdentityKeyPair().getPublicKey(),
TrustLevel.TRUSTED_VERIFIED);
try (Manager m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent)) {
m.refreshPreKeys();
m.close(false);
}
account.save();
}
private VerifyAccountResponse verifyAccountWithCode(
final String verificationCode, final String legacyPin, final String registrationLock
) throws IOException {
return accountManager.verifyAccountWithCode(verificationCode,
null,
account.getSignalProtocolStore().getLocalRegistrationId(),
true,
legacyPin,
registrationLock,
account.getSelfUnidentifiedAccessKey(),
account.isUnrestrictedUnidentifiedAccess(),
ServiceConfig.capabilities,
account.isDiscoverableByPhoneNumber());
}
@Override
public void close() throws IOException {
if (account != null) {
account.close();
account = null;
}
}
} }

View File

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

View File

@ -1,241 +0,0 @@
package org.asamk.signal.manager;
import org.asamk.signal.manager.api.AccountCheckException;
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.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.accounts.AccountsStore;
import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import java.io.File;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
public class SignalAccountFiles {
private static final Logger logger = LoggerFactory.getLogger(MultiAccountManager.class);
private final PathConfig pathConfig;
private final ServiceEnvironment serviceEnvironment;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final String userAgent;
private final Settings settings;
private final AccountsStore accountsStore;
public SignalAccountFiles(
final File settingsPath,
final ServiceEnvironment serviceEnvironment,
final String userAgent,
final Settings settings
) throws IOException {
this.pathConfig = PathConfig.createDefault(settingsPath);
this.serviceEnvironment = serviceEnvironment;
this.serviceEnvironmentConfig = ServiceConfig.getServiceEnvironmentConfig(this.serviceEnvironment, userAgent);
this.userAgent = userAgent;
this.settings = settings;
this.accountsStore = new AccountsStore(pathConfig.dataPath(), serviceEnvironment, accountPath -> {
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
return null;
}
try {
return SignalAccount.load(pathConfig.dataPath(), accountPath, false, settings);
} catch (Exception e) {
return null;
}
});
}
public Set<String> getAllLocalAccountNumbers() throws IOException {
return accountsStore.getAllNumbers();
}
public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
try {
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
} catch (NotRegisteredException e) {
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return null;
} catch (AccountCheckException | IOException e) {
logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
return new Pair<Manager, Throwable>(null, e);
}
}).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);
}
public Manager initManagerByNumber(String number) throws IOException, NotRegisteredException, AccountCheckException {
final var accountPath = accountsStore.getPathByNumber(number);
return this.initManagerByNumber(number, accountPath);
}
public Manager initManagerByAci(String aciStr) throws IOException, NotRegisteredException, AccountCheckException {
final var aci = ACI.parseOrThrow(aciStr);
final var accountPath = accountsStore.getPathByAci(aci);
return this.initManagerByAci(aci, accountPath);
}
private Manager initManagerByNumber(
String number,
String accountPath
) 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) {
throw new NotRegisteredException();
}
if (!SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
throw new NotRegisteredException();
}
return SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
}
private Manager initManagerByAci(
ACI aci,
String accountPath
) throws IOException, NotRegisteredException, AccountCheckException {
final var account = loadAccount(accountPath);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
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()) {
account.close();
throw new NotRegisteredException();
}
if (account.getServiceEnvironment() != null && account.getServiceEnvironment() != serviceEnvironment) {
throw new IOException("Account is registered in another environment: " + account.getServiceEnvironment());
}
account.initDatabase();
final var manager = new ManagerImpl(account,
pathConfig,
new AccountFileUpdaterImpl(accountsStore, accountPath),
serviceEnvironmentConfig,
userAgent);
try {
manager.checkAccountState();
} catch (DeprecatedVersionException e) {
manager.close();
throw new IOException("signal-cli version is too old for the Signal-Server, please update.");
} catch (IOException e) {
manager.close();
throw new AccountCheckException("Error while checking account " + identifier + ": " + e.getMessage(), e);
}
if (account.getServiceEnvironment() == null) {
account.setServiceEnvironment(serviceEnvironment);
accountsStore.updateAccount(accountPath, account.getNumber(), account.getAci());
}
return manager;
}
public ProvisioningManager initProvisioningManager() {
return initProvisioningManager(null);
}
public ProvisioningManager initProvisioningManager(Consumer<Manager> newManagerListener) {
return new ProvisioningManagerImpl(pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
accountsStore);
}
public RegistrationManager initRegistrationManager(String number) throws IOException {
return initRegistrationManager(number, null);
}
public RegistrationManager initRegistrationManager(
String number,
Consumer<Manager> newManagerListener
) throws IOException {
final var accountPath = accountsStore.getPathByNumber(number);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
var aciIdentityKey = KeyUtils.generateIdentityKeyPair();
var pniIdentityKey = KeyUtils.generateIdentityKeyPair();
var profileKey = KeyUtils.createProfileKey();
var account = SignalAccount.create(pathConfig.dataPath(),
newAccountPath,
number,
serviceEnvironment,
aciIdentityKey,
pniIdentityKey,
profileKey,
settings);
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
}
var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!number.equals(account.getNumber())) {
account.close();
throw new IOException("Number in account file doesn't match expected number: " + account.getNumber());
}
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
}

View File

@ -1,4 +1,4 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager;
public class StickerPackInvalidException extends Exception { public class StickerPackInvalidException extends Exception {

View File

@ -0,0 +1,42 @@
package org.asamk.signal.manager;
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
public enum TrustLevel {
UNTRUSTED,
TRUSTED_UNVERIFIED,
TRUSTED_VERIFIED;
private static TrustLevel[] cachedValues = null;
public static TrustLevel fromInt(int i) {
if (TrustLevel.cachedValues == null) {
TrustLevel.cachedValues = TrustLevel.values();
}
return TrustLevel.cachedValues[i];
}
public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) {
switch (verifiedState) {
case DEFAULT:
return TRUSTED_UNVERIFIED;
case UNVERIFIED:
return UNTRUSTED;
case VERIFIED:
return TRUSTED_VERIFIED;
}
throw new RuntimeException("Unknown verified state: " + verifiedState);
}
public VerifiedMessage.VerifiedState toVerifiedState() {
switch (this) {
case TRUSTED_UNVERIFIED:
return VerifiedMessage.VerifiedState.DEFAULT;
case UNTRUSTED:
return VerifiedMessage.VerifiedState.UNVERIFIED;
case TRUSTED_VERIFIED:
return VerifiedMessage.VerifiedState.VERIFIED;
}
throw new RuntimeException("Unknown verified state: " + this);
}
}

View File

@ -0,0 +1,22 @@
package org.asamk.signal.manager;
import java.io.File;
public class UserAlreadyExists extends Exception {
private final String username;
private final File fileName;
public UserAlreadyExists(String username, File fileName) {
this.username = username;
this.fileName = fileName;
}
public String getUsername() {
return username;
}
public File getFileName() {
return fileName;
}
}

View File

@ -1,11 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public interface HandleAction {
void execute(Context context) throws Throwable;
default void mergeOther(HandleAction action) {
}
}

View File

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

View File

@ -1,39 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.core.models.ServiceId;
public class RenewSessionAction implements HandleAction {
private final RecipientId recipientId;
private final ServiceId serviceId;
private final ServiceId accountId;
public RenewSessionAction(final RecipientId recipientId, final ServiceId serviceId, final ServiceId accountId) {
this.recipientId = recipientId;
this.serviceId = serviceId;
this.accountId = accountId;
}
@Override
public void execute(Context context) throws Throwable {
context.getAccount().getAccountData(accountId).getSessionStore().archiveSessions(serviceId);
context.getSendHelper().sendNullMessage(recipientId);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final RenewSessionAction that = (RenewSessionAction) o;
return recipientId.equals(that.recipientId);
}
@Override
public int hashCode() {
return recipientId.hashCode();
}
}

View File

@ -1,44 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.sendLog.MessageSendLogEntry;
import java.util.Objects;
public class ResendMessageAction implements HandleAction {
private final RecipientId recipientId;
private final long timestamp;
private final MessageSendLogEntry messageSendLogEntry;
public ResendMessageAction(
final RecipientId recipientId,
final long timestamp,
final MessageSendLogEntry messageSendLogEntry
) {
this.recipientId = recipientId;
this.timestamp = timestamp;
this.messageSendLogEntry = messageSendLogEntry;
}
@Override
public void execute(Context context) throws Throwable {
context.getSendHelper().resendMessage(recipientId, timestamp, messageSendLogEntry);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final ResendMessageAction that = (ResendMessageAction) o;
return timestamp == that.timestamp
&& recipientId.equals(that.recipientId)
&& messageSendLogEntry.equals(that.messageSendLogEntry);
}
@Override
public int hashCode() {
return Objects.hash(recipientId, timestamp, messageSendLogEntry);
}
}

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

@ -1,33 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class RetrieveProfileAction implements HandleAction {
private final RecipientId recipientId;
public RetrieveProfileAction(final RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public void execute(Context context) throws Throwable {
context.getProfileHelper().refreshRecipientProfile(recipientId);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final RetrieveProfileAction that = (RetrieveProfileAction) o;
return recipientId.equals(that.recipientId);
}
@Override
public int hashCode() {
return recipientId.hashCode();
}
}

View File

@ -1,39 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class SendGroupInfoAction implements HandleAction {
private final RecipientId recipientId;
private final GroupIdV1 groupId;
public SendGroupInfoAction(final RecipientId recipientId, final GroupIdV1 groupId) {
this.recipientId = recipientId;
this.groupId = groupId;
}
@Override
public void execute(Context context) throws Throwable {
context.getGroupHelper().sendGroupInfoMessage(groupId, recipientId);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final var that = (SendGroupInfoAction) o;
if (!recipientId.equals(that.recipientId)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
var result = recipientId.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}

View File

@ -1,39 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class SendGroupInfoRequestAction implements HandleAction {
private final RecipientId recipientId;
private final GroupIdV1 groupId;
public SendGroupInfoRequestAction(final RecipientId recipientId, final GroupIdV1 groupId) {
this.recipientId = recipientId;
this.groupId = groupId;
}
@Override
public void execute(Context context) throws Throwable {
context.getGroupHelper().sendGroupInfoRequest(groupId, recipientId);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final var that = (SendGroupInfoRequestAction) o;
if (!recipientId.equals(that.recipientId)) return false;
return groupId.equals(that.groupId);
}
@Override
public int hashCode() {
var result = recipientId.hashCode();
result = 31 * result + groupId.hashCode();
return result;
}
}

View File

@ -1,33 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import java.util.Objects;
public class SendProfileKeyAction implements HandleAction {
private final RecipientId recipientId;
public SendProfileKeyAction(final RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public void execute(Context context) throws Throwable {
context.getSendHelper().sendProfileKey(recipientId);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendProfileKeyAction that = (SendProfileKeyAction) o;
return recipientId.equals(that.recipientId);
}
@Override
public int hashCode() {
return Objects.hash(recipientId);
}
}

View File

@ -1,55 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class SendReceiptAction implements HandleAction {
private final RecipientId recipientId;
private final SignalServiceReceiptMessage.Type type;
private final List<Long> timestamps = new ArrayList<>();
public SendReceiptAction(
final RecipientId recipientId,
final SignalServiceReceiptMessage.Type type,
final long timestamp
) {
this.recipientId = recipientId;
this.type = type;
this.timestamps.add(timestamp);
}
@Override
public void execute(Context context) throws Throwable {
final var receiptMessage = new SignalServiceReceiptMessage(type, timestamps, System.currentTimeMillis());
context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
}
@Override
public void mergeOther(final HandleAction action) {
if (action instanceof SendReceiptAction sendReceiptAction) {
this.timestamps.addAll(sendReceiptAction.timestamps);
}
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendReceiptAction that = (SendReceiptAction) o;
// Using only recipientId and type here on purpose
return recipientId.equals(that.recipientId) && type == that.type;
}
@Override
public int hashCode() {
// Using only recipientId and type here on purpose
return Objects.hash(recipientId, type);
}
}

View File

@ -1,89 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.metadata.ProtocolException;
import org.signal.libsignal.protocol.message.CiphertextMessage;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.Envelope;
import java.util.Optional;
public class SendRetryMessageRequestAction implements HandleAction {
private final RecipientId recipientId;
private final ProtocolException protocolException;
private final SignalServiceEnvelope envelope;
public SendRetryMessageRequestAction(
final RecipientId recipientId,
final ProtocolException protocolException,
final SignalServiceEnvelope envelope
) {
this.recipientId = recipientId;
this.protocolException = protocolException;
this.envelope = envelope;
}
@Override
public void execute(Context context) throws Throwable {
int senderDevice = protocolException.getSenderDevice();
Optional<GroupId> groupId = protocolException.getGroupId().isPresent() ? Optional.of(GroupId.unknownVersion(
protocolException.getGroupId().get())) : Optional.empty();
byte[] originalContent;
int envelopeType;
if (protocolException.getUnidentifiedSenderMessageContent().isPresent()) {
final var messageContent = protocolException.getUnidentifiedSenderMessageContent().get();
originalContent = messageContent.getContent();
envelopeType = messageContent.getType();
} else {
originalContent = envelope.getContent();
envelopeType = envelope.getType() == null
? CiphertextMessage.WHISPER_TYPE
: envelopeTypeToCiphertextMessageType(envelope.getType());
}
DecryptionErrorMessage decryptionErrorMessage = DecryptionErrorMessage.forOriginalMessage(originalContent,
envelopeType,
envelope.getTimestamp(),
senderDevice);
context.getSendHelper().sendRetryReceipt(decryptionErrorMessage, recipientId, groupId);
}
private static int envelopeTypeToCiphertextMessageType(int envelopeType) {
final var type = Envelope.Type.fromValue(envelopeType);
if (type == null) {
return CiphertextMessage.WHISPER_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;
};
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SendRetryMessageRequestAction that = (SendRetryMessageRequestAction) o;
if (!recipientId.equals(that.recipientId)) return false;
if (!protocolException.equals(that.protocolException)) return false;
return envelope.equals(that.envelope);
}
@Override
public int hashCode() {
int result = recipientId.hashCode();
result = 31 * result + protocolException.hashCode();
result = 31 * result + envelope.hashCode();
return result;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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,20 +0,0 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public class UpdateAccountAttributesAction implements HandleAction {
private static final UpdateAccountAttributesAction INSTANCE = new UpdateAccountAttributesAction();
private UpdateAccountAttributesAction() {
}
public static UpdateAccountAttributesAction create() {
return INSTANCE;
}
@Override
public void execute(Context context) throws Throwable {
context.getAccountHelper().updateAccountAttributes();
}
}

View File

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

View File

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

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

@ -1,23 +0,0 @@
package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception {
private long nextVerificationAttemptMilliseconds;
public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) {
super("Captcha required");
this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds;
}
public CaptchaRequiredException(final String message) {
super(message);
}
public CaptchaRequiredException(final String message, final Throwable cause) {
super(message, cause);
}
public long getNextVerificationAttemptMilliseconds() {
return nextVerificationAttemptMilliseconds;
}
}

View File

@ -1,24 +0,0 @@
package org.asamk.signal.manager.api;
public record Color(int color) {
public int alpha() {
return color >>> 24;
}
public int red() {
return (color >> 16) & 0xFF;
}
public int green() {
return (color >> 8) & 0xFF;
}
public int blue() {
return color & 0xFF;
}
public String toHexColor() {
return String.format("#%08x", color);
}
}

View File

@ -1,20 +0,0 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.storage.configuration.ConfigurationStore;
import java.util.Optional;
public record Configuration(
Optional<Boolean> readReceipts,
Optional<Boolean> unidentifiedDeliveryIndicators,
Optional<Boolean> typingIndicators,
Optional<Boolean> linkPreviews
) {
public static Configuration from(final ConfigurationStore configurationStore) {
return new Configuration(Optional.ofNullable(configurationStore.getReadReceipts()),
Optional.ofNullable(configurationStore.getUnidentifiedDeliveryIndicators()),
Optional.ofNullable(configurationStore.getTypingIndicators()),
Optional.ofNullable(configurationStore.getLinkPreviews()));
}
}

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,3 +0,0 @@
package org.asamk.signal.manager.api;
public record Device(int id, String name, long created, long lastSeen, boolean isThisDevice) {}

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,58 +0,0 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public record DeviceLinkUrl(String deviceIdentifier, ECPublicKey deviceKey) {
public static DeviceLinkUrl parseDeviceLinkUri(URI linkUri) throws InvalidDeviceLinkException {
final var rawQuery = linkUri.getRawQuery();
if (isEmpty(rawQuery)) {
throw new RuntimeException("Invalid device link uri");
}
var query = Utils.getQueryMap(rawQuery);
var deviceIdentifier = query.get("uuid");
var publicKeyEncoded = query.get("pub_key");
if (isEmpty(deviceIdentifier) || isEmpty(publicKeyEncoded)) {
throw new InvalidDeviceLinkException("Invalid device link uri");
}
final byte[] publicKeyBytes;
try {
publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);
} catch (IllegalArgumentException e) {
throw new InvalidDeviceLinkException("Invalid device link uri", e);
}
ECPublicKey deviceKey;
try {
deviceKey = new ECPublicKey(publicKeyBytes);
} catch (InvalidKeyException e) {
throw new InvalidDeviceLinkException("Invalid device link", e);
}
return new DeviceLinkUrl(deviceIdentifier, deviceKey);
}
public URI createDeviceLinkUri() {
final var deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
try {
return new URI("sgnl://linkdevice?uuid="
+ URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
+ "&pub_key="
+ URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8));
} catch (URISyntaxException e) {
throw new AssertionError(e);
}
}
}

View File

@ -1,66 +0,0 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import java.util.Set;
import java.util.stream.Collectors;
public record Group(
GroupId groupId,
String title,
String description,
GroupInviteLinkUrl groupInviteLinkUrl,
Set<GroupMember> members,
Set<RecipientAddress> pendingMembers,
Set<RecipientAddress> requestingMembers,
Set<RecipientAddress> bannedMembers,
boolean isBlocked,
int messageExpirationTimer,
GroupPermission permissionAddMember,
GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage,
boolean isMember,
boolean isAdmin,
boolean isTerminated
) {
public static Group from(
final GroupInfo groupInfo,
final RecipientAddressResolver recipientStore,
final RecipientId selfRecipientId
) {
return new Group(groupInfo.getGroupId(),
groupInfo.getTitle(),
groupInfo.getDescription(),
groupInfo.getGroupInviteLink(),
groupInfo.getMembers()
.stream()
.map(m -> org.asamk.signal.manager.api.GroupMember.from(m, recipientStore))
.collect(Collectors.toSet()),
groupInfo.getPendingMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getRequestingMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getBannedMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.isBlocked(),
groupInfo.getMessageExpirationTimer(),
groupInfo.getPermissionAddMember(),
groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId),
groupInfo.isTerminated());
}
}

View File

@ -1,20 +0,0 @@
package org.asamk.signal.manager.api;
import java.util.Base64;
import static org.asamk.signal.manager.util.KeyUtils.getSecretBytes;
public final class GroupIdV1 extends GroupId {
public static GroupIdV1 createRandom() {
return new GroupIdV1(getSecretBytes(16));
}
public static GroupIdV1 fromBase64(String groupId) {
return new GroupIdV1(Base64.getDecoder().decode(groupId));
}
public GroupIdV1(final byte[] id) {
super(id);
}
}

View File

@ -1,7 +0,0 @@
package org.asamk.signal.manager.api;
public enum GroupLinkState {
ENABLED,
ENABLED_WITH_APPROVAL,
DISABLED,
}

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

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