mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-11-03 10:10:16 +00:00
Compare commits
No commits in common. "master" and "v0.14.8" have entirely different histories.
16
CHANGELOG.md
16
CHANGELOG.md
@ -1,21 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [0.14.9] - 2026-10-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded
|
|
||||||
- Support linking phone-numberless Signal accounts
|
|
||||||
- Support recovering accounts with a recovery key
|
|
||||||
- Support replaying missed SSE events and configuring keep-alive intervals
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Show the actual error for failed requests
|
|
||||||
- Improve attachment upload and download failure handling
|
|
||||||
|
|
||||||
## [0.14.8] - 2026-09-10
|
## [0.14.8] - 2026-09-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -5,7 +5,6 @@ It supports registering, verifying, sending and receiving messages.
|
|||||||
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
|
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
|
||||||
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service).
|
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/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.
|
||||||
Existing accounts without a phone number can be linked from a compatible Signal mobile app.
|
|
||||||
|
|
||||||
signal-cli is primarily intended to be used on servers to notify admins of important events.
|
signal-cli is primarily intended to be used on servers to notify admins of important events.
|
||||||
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
|
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))
|
||||||
@ -61,16 +60,14 @@ 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 [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 [wiki](https://github.com/AsamK/signal-cli/wiki).
|
||||||
|
|
||||||
For a numbered account, ACCOUNT is your phone number in international format and must include the country calling code. Hence it
|
Important: The ACCOUNT 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
|
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
|
||||||
of all country codes.)
|
of all country codes.)
|
||||||
For a linked account without a phone number, use its ACI (Account ID) instead.
|
|
||||||
See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
|
|
||||||
|
|
||||||
* Link to an existing account
|
* Link to an existing account
|
||||||
|
|
||||||
If you have an existing Signal account, with or without a phone number,
|
If you have an existing Signal account associated
|
||||||
you can link signal-cli to it with:
|
with a number, you can link signal-cli to it with:
|
||||||
|
|
||||||
signal-cli link
|
signal-cli link
|
||||||
|
|
||||||
|
|||||||
@ -5,12 +5,12 @@ plugins {
|
|||||||
application
|
application
|
||||||
eclipse
|
eclipse
|
||||||
`check-lib-versions`
|
`check-lib-versions`
|
||||||
id("org.graalvm.buildtools.native") version "1.1.14"
|
id("org.graalvm.buildtools.native") version "1.1.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = "org.asamk"
|
group = "org.asamk"
|
||||||
version = "0.14.10-SNAPSHOT"
|
version = "0.14.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
|
|||||||
6
client/Cargo.lock
generated
6
client/Cargo.lock
generated
@ -850,9 +850,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.46"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -1014,7 +1014,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-cli-client"
|
name = "signal-cli-client"
|
||||||
version = "0.14.9"
|
version = "0.14.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "signal-cli-client"
|
name = "signal-cli-client"
|
||||||
version = "0.14.9"
|
version = "0.14.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
@ -45,9 +45,6 @@
|
|||||||
<content_attribute id="social-chat">intense</content_attribute>
|
<content_attribute id="social-chat">intense</content_attribute>
|
||||||
</content_rating>
|
</content_rating>
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.14.9" date="2026-10-07">
|
|
||||||
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.9</url>
|
|
||||||
</release>
|
|
||||||
<release version="0.14.8" date="2026-09-10">
|
<release version="0.14.8" date="2026-09-10">
|
||||||
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url>
|
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url>
|
||||||
</release>
|
</release>
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
[versions]
|
[versions]
|
||||||
slf4j = "2.0.20"
|
slf4j = "2.0.19"
|
||||||
coroutines = "1.11.0"
|
coroutines = "1.11.0"
|
||||||
junit = "6.1.3"
|
junit = "6.1.3"
|
||||||
micronaut-json-schema = "2.3.1"
|
micronaut-json-schema = "2.2.0"
|
||||||
micronaut-core = "5.2.14"
|
micronaut-core = "5.2.0"
|
||||||
signal-service = "2.15.3_unofficial_156"
|
signal-service = "2.15.3_unofficial_153"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86"
|
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85.2"
|
||||||
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.3"
|
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.2"
|
||||||
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
|
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
|
||||||
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
|
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
|
||||||
zxing = "com.google.zxing:core:3.5.4"
|
zxing = "com.google.zxing:core:3.5.4"
|
||||||
@ -18,7 +18,7 @@ micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-
|
|||||||
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
|
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
|
||||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||||
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
|
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
|
||||||
logback = "ch.qos.logback:logback-classic:1.6.5"
|
logback = "ch.qos.logback:logback-classic:1.6.3"
|
||||||
|
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
|
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
|
||||||
|
|||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
52
gradlew.bat
vendored
52
gradlew.bat
vendored
@ -26,33 +26,6 @@
|
|||||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
setlocal EnableExtensions
|
setlocal EnableExtensions
|
||||||
|
|
||||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
|
||||||
@rem This can be removed once we can be reasonably confident that few people
|
|
||||||
@rem will be migrating directly to this new wrapper.
|
|
||||||
goto afterSafetyNet
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
||||||
goto exitWithErrorLevel
|
|
||||||
:afterSafetyNet
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
@ -72,14 +45,13 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
1>&2 echo.
|
echo. 1>&2
|
||||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
1>&2 echo.
|
echo. 1>&2
|
||||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
1>&2 echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
goto exitWithErrorLevel
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@ -87,14 +59,13 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
1>&2 echo.
|
echo. 1>&2
|
||||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
1>&2 echo.
|
echo. 1>&2
|
||||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
1>&2 echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
"%COMSPEC%" /c exit 1
|
"%COMSPEC%" /c exit 1
|
||||||
goto exitWithErrorLevel
|
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
@ -104,9 +75,8 @@ goto exitWithErrorLevel
|
|||||||
@rem Execute gradlew
|
@rem Execute gradlew
|
||||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
@rem which allows us to clear the local environment before executing the java command
|
@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 & goto exitWithErrorLevel
|
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
|
||||||
|
|
||||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
|
||||||
:exitWithErrorLevel
|
:exitWithErrorLevel
|
||||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|||||||
@ -98,14 +98,6 @@ public interface Manager extends Closeable {
|
|||||||
|
|
||||||
String getSelfACI();
|
String getSelfACI();
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the phone number for numbered accounts, or the ACI for numberless accounts.
|
|
||||||
*/
|
|
||||||
default String getSelfIdentifier() {
|
|
||||||
final var number = getSelfNumber();
|
|
||||||
return number != null ? number : getSelfACI();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is used for checking a set of phone numbers for registration on Signal
|
* This is used for checking a set of phone numbers for registration on Signal
|
||||||
*
|
*
|
||||||
|
|||||||
@ -8,16 +8,14 @@ import java.util.function.Consumer;
|
|||||||
|
|
||||||
public interface MultiAccountManager extends AutoCloseable {
|
public interface MultiAccountManager extends AutoCloseable {
|
||||||
|
|
||||||
|
List<String> getAccountNumbers();
|
||||||
|
|
||||||
List<Manager> getManagers();
|
List<Manager> getManagers();
|
||||||
|
|
||||||
void addOnManagerAddedHandler(Consumer<Manager> handler);
|
void addOnManagerAddedHandler(Consumer<Manager> handler);
|
||||||
|
|
||||||
void removeOnManagerAddedHandler(Consumer<Manager> handler);
|
|
||||||
|
|
||||||
void addOnManagerRemovedHandler(Consumer<Manager> handler);
|
void addOnManagerRemovedHandler(Consumer<Manager> handler);
|
||||||
|
|
||||||
void removeOnManagerRemovedHandler(Consumer<Manager> handler);
|
|
||||||
|
|
||||||
Manager getManager(String phoneNumber);
|
Manager getManager(String phoneNumber);
|
||||||
|
|
||||||
URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException;
|
URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException;
|
||||||
|
|||||||
@ -10,8 +10,5 @@ public interface ProvisioningManager {
|
|||||||
|
|
||||||
URI getDeviceLinkUri() throws TimeoutException, IOException;
|
URI getDeviceLinkUri() throws TimeoutException, IOException;
|
||||||
|
|
||||||
/**
|
|
||||||
* Completes linking and returns the account's phone number, or ACI if it has no number.
|
|
||||||
*/
|
|
||||||
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException;
|
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
|||||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||||
import org.asamk.signal.manager.api.PinLockedException;
|
import org.asamk.signal.manager.api.PinLockedException;
|
||||||
import org.asamk.signal.manager.api.RateLimitException;
|
import org.asamk.signal.manager.api.RateLimitException;
|
||||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
|
||||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||||
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
@ -18,19 +17,13 @@ public interface RegistrationManager extends Closeable {
|
|||||||
boolean voiceVerification,
|
boolean voiceVerification,
|
||||||
String captcha,
|
String captcha,
|
||||||
final boolean forceRegister
|
final boolean forceRegister
|
||||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException;
|
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
|
||||||
|
|
||||||
void verifyAccount(
|
void verifyAccount(
|
||||||
String verificationCode,
|
String verificationCode,
|
||||||
String pin
|
String pin
|
||||||
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
|
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
|
||||||
|
|
||||||
void registerWithRecoveryKey(
|
|
||||||
String recoveryKey,
|
|
||||||
boolean forceRegister,
|
|
||||||
Integer totp
|
|
||||||
) throws IOException, RateLimitException, TwoFactorRequired;
|
|
||||||
|
|
||||||
void deleteLocalAccountData() throws IOException;
|
void deleteLocalAccountData() throws IOException;
|
||||||
|
|
||||||
boolean isRegistered();
|
boolean isRegistered();
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import java.io.IOException;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
public class SignalAccountFiles {
|
public class SignalAccountFiles {
|
||||||
|
|
||||||
@ -66,26 +65,15 @@ public class SignalAccountFiles {
|
|||||||
return accountsStore.getAllNumbers();
|
return accountsStore.getAllNumbers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<String> getAllLocalAccountIdentifiers() throws IOException {
|
|
||||||
return accountsStore.getAllAccounts()
|
|
||||||
.stream()
|
|
||||||
.map(a -> a.number() != null ? a.number() : a.uuid())
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
public MultiAccountManager initMultiAccountManager() throws IOException {
|
public MultiAccountManager initMultiAccountManager() throws IOException {
|
||||||
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
|
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
|
||||||
final var identifier = a.number() != null ? a.number() : a.uuid();
|
|
||||||
try {
|
try {
|
||||||
final var manager = a.number() != null
|
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
|
||||||
? initManagerByNumber(a.number(), a.path())
|
|
||||||
: initManagerByAci(ACI.parseOrThrow(a.uuid()), a.path());
|
|
||||||
return new Pair<Manager, Throwable>(manager, null);
|
|
||||||
} catch (NotRegisteredException e) {
|
} catch (NotRegisteredException e) {
|
||||||
logger.warn("Ignoring {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
|
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
|
||||||
return null;
|
return null;
|
||||||
} catch (AccountCheckException | IOException e) {
|
} catch (AccountCheckException | IOException e) {
|
||||||
logger.error("Failed to load {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
|
logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
|
||||||
return new Pair<Manager, Throwable>(null, e);
|
return new Pair<Manager, Throwable>(null, e);
|
||||||
}
|
}
|
||||||
}).filter(Objects::nonNull).toList();
|
}).filter(Objects::nonNull).toList();
|
||||||
@ -211,11 +199,6 @@ public class SignalAccountFiles {
|
|||||||
String number,
|
String number,
|
||||||
Consumer<Manager> newManagerListener
|
Consumer<Manager> newManagerListener
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
final var aci = ACI.parseOrNull(number);
|
|
||||||
if (aci != null) {
|
|
||||||
return initRegistrationManager(aci, newManagerListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
final var accountPath = accountsStore.getPathByNumber(number);
|
final var accountPath = accountsStore.getPathByNumber(number);
|
||||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
||||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
|
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
|
||||||
@ -255,43 +238,4 @@ public class SignalAccountFiles {
|
|||||||
newManagerListener,
|
newManagerListener,
|
||||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
private RegistrationManager initRegistrationManager(
|
|
||||||
final ACI aci,
|
|
||||||
final Consumer<Manager> newManagerListener
|
|
||||||
) throws IOException {
|
|
||||||
final var accountPath = accountsStore.getPathByAci(aci);
|
|
||||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
|
||||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
|
|
||||||
final var account = SignalAccount.create(pathConfig.dataPath(),
|
|
||||||
newAccountPath,
|
|
||||||
null,
|
|
||||||
aci,
|
|
||||||
serviceEnvironment,
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
settings);
|
|
||||||
account.initDatabase();
|
|
||||||
return new RegistrationManagerImpl(account,
|
|
||||||
pathConfig,
|
|
||||||
serviceEnvironmentConfig,
|
|
||||||
userAgent,
|
|
||||||
newManagerListener,
|
|
||||||
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
|
|
||||||
if (!aci.equals(account.getAci())) {
|
|
||||||
account.close();
|
|
||||||
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
|
|
||||||
}
|
|
||||||
account.initDatabase();
|
|
||||||
return new RegistrationManagerImpl(account,
|
|
||||||
pathConfig,
|
|
||||||
serviceEnvironmentConfig,
|
|
||||||
userAgent,
|
|
||||||
newManagerListener,
|
|
||||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
package org.asamk.signal.manager.api;
|
|
||||||
|
|
||||||
import org.signal.libsignal.net.BadRequestError;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class BadRequestException extends IOException {
|
|
||||||
|
|
||||||
private final BadRequestError error;
|
|
||||||
|
|
||||||
public BadRequestException(final BadRequestError error) {
|
|
||||||
super(error.toString());
|
|
||||||
this.error = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BadRequestError getError() {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@ import java.util.Optional;
|
|||||||
public record Message(
|
public record Message(
|
||||||
String messageText,
|
String messageText,
|
||||||
List<String> attachments,
|
List<String> attachments,
|
||||||
List<AttachmentDimensions> attachmentDimensions,
|
|
||||||
List<String> attachmentBlurHashes,
|
|
||||||
boolean viewOnce,
|
boolean viewOnce,
|
||||||
boolean voiceNote,
|
boolean voiceNote,
|
||||||
List<Mention> mentions,
|
List<Mention> mentions,
|
||||||
@ -19,8 +17,6 @@ public record Message(
|
|||||||
boolean urgent
|
boolean urgent
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public record AttachmentDimensions(int width, int height) {}
|
|
||||||
|
|
||||||
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
|
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
|
||||||
|
|
||||||
public record Quote(
|
public record Quote(
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
package org.asamk.signal.manager.api;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class TwoFactorRequired extends IOException {
|
|
||||||
|
|
||||||
public TwoFactorRequired() {
|
|
||||||
super("A TOTP token is required or the supplied token is incorrect");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,6 +3,7 @@ package org.asamk.signal.manager.config;
|
|||||||
import org.signal.libsignal.net.Network.Environment;
|
import org.signal.libsignal.net.Network.Environment;
|
||||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||||
|
import org.signal.network.config.HttpProxy;
|
||||||
import org.signal.network.config.SignalCdnUrl;
|
import org.signal.network.config.SignalCdnUrl;
|
||||||
import org.signal.network.config.SignalCdsiUrl;
|
import org.signal.network.config.SignalCdsiUrl;
|
||||||
import org.signal.network.config.SignalProxy;
|
import org.signal.network.config.SignalProxy;
|
||||||
@ -43,6 +44,7 @@ class LiveConfig {
|
|||||||
|
|
||||||
private static final Optional<Dns> dns = Optional.empty();
|
private static final Optional<Dns> dns = Optional.empty();
|
||||||
private static final Optional<SignalProxy> proxy = Optional.empty();
|
private static final Optional<SignalProxy> proxy = Optional.empty();
|
||||||
|
private static final Optional<HttpProxy> systemProxy = Optional.empty();
|
||||||
|
|
||||||
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
||||||
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==");
|
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==");
|
||||||
@ -70,6 +72,7 @@ class LiveConfig {
|
|||||||
interceptors,
|
interceptors,
|
||||||
dns,
|
dns,
|
||||||
proxy,
|
proxy,
|
||||||
|
systemProxy,
|
||||||
zkGroupServerPublicParams,
|
zkGroupServerPublicParams,
|
||||||
genericServerPublicParams,
|
genericServerPublicParams,
|
||||||
backupServerPublicParams,
|
backupServerPublicParams,
|
||||||
|
|||||||
@ -28,14 +28,11 @@ public class ServiceConfig {
|
|||||||
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
|
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
|
||||||
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
|
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
|
||||||
|
|
||||||
public static AccountAttributes.Capabilities getCapabilities(
|
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
|
||||||
final boolean isPrimaryDevice,
|
|
||||||
final boolean hasPhoneNumber
|
|
||||||
) {
|
|
||||||
final var attachmentBackfill = !isPrimaryDevice;
|
final var attachmentBackfill = !isPrimaryDevice;
|
||||||
final var spqr = true;
|
final var spqr = true;
|
||||||
final var usernameSyncChangeMessage = !isPrimaryDevice;
|
final var usernameSyncChangeMessage = !isPrimaryDevice;
|
||||||
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber;
|
final var optionalPhoneNumber = !isPrimaryDevice;
|
||||||
return new AccountAttributes.Capabilities(true,
|
return new AccountAttributes.Capabilities(true,
|
||||||
true,
|
true,
|
||||||
attachmentBackfill,
|
attachmentBackfill,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package org.asamk.signal.manager.config;
|
|||||||
import org.signal.libsignal.net.Network;
|
import org.signal.libsignal.net.Network;
|
||||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||||
|
import org.signal.network.config.HttpProxy;
|
||||||
import org.signal.network.config.SignalCdnUrl;
|
import org.signal.network.config.SignalCdnUrl;
|
||||||
import org.signal.network.config.SignalCdsiUrl;
|
import org.signal.network.config.SignalCdsiUrl;
|
||||||
import org.signal.network.config.SignalProxy;
|
import org.signal.network.config.SignalProxy;
|
||||||
@ -43,6 +44,7 @@ class StagingConfig {
|
|||||||
|
|
||||||
private static final Optional<Dns> dns = Optional.empty();
|
private static final Optional<Dns> dns = Optional.empty();
|
||||||
private static final Optional<SignalProxy> proxy = Optional.empty();
|
private static final Optional<SignalProxy> proxy = Optional.empty();
|
||||||
|
private static final Optional<HttpProxy> systemProxy = Optional.empty();
|
||||||
|
|
||||||
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
private static final byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
||||||
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==");
|
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==");
|
||||||
@ -70,6 +72,7 @@ class StagingConfig {
|
|||||||
interceptors,
|
interceptors,
|
||||||
dns,
|
dns,
|
||||||
proxy,
|
proxy,
|
||||||
|
systemProxy,
|
||||||
zkGroupServerPublicParams,
|
zkGroupServerPublicParams,
|
||||||
genericServerPublicParams,
|
genericServerPublicParams,
|
||||||
backupServerPublicParams,
|
backupServerPublicParams,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package org.asamk.signal.manager.helper;
|
package org.asamk.signal.manager.helper;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.BadRequestException;
|
|
||||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||||
import org.asamk.signal.manager.api.DeviceLinkUrl;
|
import org.asamk.signal.manager.api.DeviceLinkUrl;
|
||||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||||
@ -28,7 +27,6 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
|||||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||||
import org.signal.libsignal.usernames.Username;
|
import org.signal.libsignal.usernames.Username;
|
||||||
import org.signal.network.api.AccountApiV2.SetAccountAttributesError;
|
|
||||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@ -98,10 +96,10 @@ public class AccountHelper {
|
|||||||
} else {
|
} else {
|
||||||
context.getPreKeyHelper().refreshPreKeysIfNecessary();
|
context.getPreKeyHelper().refreshPreKeysIfNecessary();
|
||||||
}
|
}
|
||||||
if (account.getPni() == null && account.getNumber() != null) {
|
if (account.getPni() == null) {
|
||||||
checkWhoAmiI();
|
checkWhoAmiI();
|
||||||
}
|
}
|
||||||
if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) {
|
if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
||||||
throw new IOException("Missing PNI identity key, relinking required");
|
throw new IOException("Missing PNI identity key, relinking required");
|
||||||
}
|
}
|
||||||
if (account.getPreviousStorageVersion() < 10
|
if (account.getPreviousStorageVersion() < 10
|
||||||
@ -140,9 +138,8 @@ public class AccountHelper {
|
|||||||
final var whoAmI = dependencies.getAccountManager().getWhoAmI();
|
final var whoAmI = dependencies.getAccountManager().getWhoAmI();
|
||||||
final var number = whoAmI.getNumber();
|
final var number = whoAmI.getNumber();
|
||||||
final var aci = ACI.parseOrThrow(whoAmI.getAci());
|
final var aci = ACI.parseOrThrow(whoAmI.getAci());
|
||||||
final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni());
|
final var pni = PNI.parseOrThrow(whoAmI.getPni());
|
||||||
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni,
|
if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) {
|
||||||
account.getPni())) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,7 +150,7 @@ public class AccountHelper {
|
|||||||
account.setNumber(number);
|
account.setNumber(number);
|
||||||
account.setAci(aci);
|
account.setAci(aci);
|
||||||
account.setPni(pni);
|
account.setPni(pni);
|
||||||
if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
||||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||||
}
|
}
|
||||||
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
|
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
|
||||||
@ -560,27 +557,14 @@ public class AccountHelper {
|
|||||||
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||||
.getDevices(cont));
|
.getDevices(cont));
|
||||||
final var deviceId = account.getDeviceId();
|
final var deviceId = account.getDeviceId();
|
||||||
final var device = devices.stream().filter(d -> d.getId() == deviceId).findFirst();
|
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
|
||||||
if (device.isPresent()) {
|
if (device.isPresent()) {
|
||||||
account.setEncryptedDeviceName(device.get().getName());
|
account.setEncryptedDeviceName(device.get().name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateAccountAttributes() throws IOException {
|
public void updateAccountAttributes() throws IOException {
|
||||||
if (account.getNumber() != null) {
|
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
|
||||||
handleResponseException(dependencies.getAccountApi()
|
|
||||||
.setAccountAttributes(account.getAccountAttributes(null)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
|
|
||||||
.setAccountAttributes(account.getAccountAttributesV2(), cont));
|
|
||||||
} catch (BadRequestException e) {
|
|
||||||
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
|
|
||||||
throw new AuthorizationFailedException(401, "Authorization failed!");
|
|
||||||
}
|
|
||||||
throw new IOException("Account attribute update rate limited; try again later");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {
|
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
package org.asamk.signal.manager.helper;
|
package org.asamk.signal.manager.helper;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
||||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
|
||||||
import org.asamk.signal.manager.api.Pair;
|
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
import org.asamk.signal.manager.config.ServiceConfig;
|
||||||
import org.asamk.signal.manager.internal.SignalDependencies;
|
import org.asamk.signal.manager.internal.SignalDependencies;
|
||||||
import org.asamk.signal.manager.storage.AttachmentStore;
|
import org.asamk.signal.manager.storage.AttachmentStore;
|
||||||
import org.asamk.signal.manager.util.AttachmentUtils;
|
import org.asamk.signal.manager.util.AttachmentUtils;
|
||||||
import org.asamk.signal.manager.util.IOUtils;
|
import org.asamk.signal.manager.util.IOUtils;
|
||||||
import org.asamk.signal.manager.util.MimeUtils;
|
|
||||||
import org.asamk.signal.manager.util.Utils;
|
import org.asamk.signal.manager.util.Utils;
|
||||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -23,7 +20,6 @@ import org.whispersystems.signalservice.api.util.StreamDetails;
|
|||||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@ -31,8 +27,6 @@ import java.io.OutputStream;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
public class AttachmentHelper {
|
public class AttachmentHelper {
|
||||||
|
|
||||||
@ -40,21 +34,12 @@ public class AttachmentHelper {
|
|||||||
|
|
||||||
private final SignalDependencies dependencies;
|
private final SignalDependencies dependencies;
|
||||||
private final AttachmentStore attachmentStore;
|
private final AttachmentStore attachmentStore;
|
||||||
private final Uploader uploader;
|
private final Context context;
|
||||||
private final Supplier<File> dataPath;
|
|
||||||
|
|
||||||
public AttachmentHelper(final Context context) {
|
public AttachmentHelper(final Context context) {
|
||||||
|
this.context = context;
|
||||||
this.dependencies = context.getDependencies();
|
this.dependencies = context.getDependencies();
|
||||||
this.attachmentStore = context.getAttachmentStore();
|
this.attachmentStore = context.getAttachmentStore();
|
||||||
this.uploader = new DependenciesUploader(dependencies);
|
|
||||||
this.dataPath = () -> context.getAccount().getDataPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
AttachmentHelper(final Uploader uploader, final Supplier<File> dataPath) {
|
|
||||||
this.dependencies = null;
|
|
||||||
this.attachmentStore = null;
|
|
||||||
this.uploader = uploader;
|
|
||||||
this.dataPath = dataPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public File getAttachmentFile(SignalServiceAttachmentPointer pointer) {
|
public File getAttachmentFile(SignalServiceAttachmentPointer pointer) {
|
||||||
@ -65,101 +50,75 @@ public class AttachmentHelper {
|
|||||||
return attachmentStore.retrieveAttachment(id);
|
return attachmentStore.retrieveAttachment(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepares and uploads all attachments. I/O failures are reported as {@link AttachmentInvalidException}, so
|
|
||||||
* callers know that nothing has been sent yet. Attachments after the failing one are not uploaded.
|
|
||||||
*/
|
|
||||||
public List<SignalServiceAttachment> uploadAttachments(
|
public List<SignalServiceAttachment> uploadAttachments(
|
||||||
final List<String> attachments,
|
final List<String> attachments,
|
||||||
final List<AttachmentDimensions> dimensions,
|
|
||||||
final List<String> blurHashes,
|
|
||||||
boolean voiceNote
|
boolean voiceNote
|
||||||
) throws AttachmentInvalidException {
|
) throws AttachmentInvalidException, IOException {
|
||||||
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote);
|
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload attachments here, so we only upload once even for multiple recipients
|
// Upload attachments here, so we only upload once even for multiple recipients
|
||||||
final var attachmentPointers = new ArrayList<SignalServiceAttachment>(attachmentStreams.size());
|
final var attachmentPointers = new ArrayList<SignalServiceAttachment>(attachmentStreams.size());
|
||||||
for (var i = 0; i < attachmentStreams.size(); i++) {
|
for (final var attachmentStream : attachmentStreams) {
|
||||||
final var attachment = attachments.get(i);
|
attachmentPointers.add(uploadAttachment(attachmentStream));
|
||||||
try {
|
|
||||||
attachmentPointers.add(uploader.upload(attachmentStreams.get(i)));
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw invalid(describe(attachment, inlineAttachmentLabel(i)), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return attachmentPointers;
|
return attachmentPointers;
|
||||||
} finally {
|
} finally {
|
||||||
closeAll(attachmentStreams);
|
for (final var attachmentStream : attachmentStreams) {
|
||||||
|
attachmentStream.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException {
|
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
|
||||||
return uploadAttachments(attachments, List.of(), List.of(), false);
|
return uploadAttachments(attachments, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SignalServiceAttachmentStream> createAttachmentStreams(
|
private List<SignalServiceAttachmentStream> createAttachmentStreams(
|
||||||
List<String> attachments,
|
List<String> attachments,
|
||||||
List<AttachmentDimensions> dimensions,
|
|
||||||
List<String> blurHashes,
|
|
||||||
boolean voiceNote
|
boolean voiceNote
|
||||||
) throws AttachmentInvalidException {
|
) throws AttachmentInvalidException, IOException {
|
||||||
if (attachments == null) {
|
if (attachments == null) {
|
||||||
return List.of();
|
return null;
|
||||||
}
|
}
|
||||||
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
|
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
|
||||||
for (var i = 0; i < attachments.size(); i++) {
|
for (var attachment : attachments) {
|
||||||
final var size = i < dimensions.size() ? dimensions.get(i) : null;
|
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
|
||||||
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null;
|
signalServiceAttachments.add(attachmentStream);
|
||||||
signalServiceAttachments.add(getAttachmentStream(attachments.get(i),
|
|
||||||
inlineAttachmentLabel(i),
|
|
||||||
size,
|
|
||||||
blurHash,
|
|
||||||
voiceNote));
|
|
||||||
}
|
}
|
||||||
return signalServiceAttachments;
|
return signalServiceAttachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SignalServiceAttachmentStream getAttachmentStream(
|
private SignalServiceAttachmentStream getAttachmentStream(
|
||||||
final String attachment,
|
final String attachment,
|
||||||
final String inlineLabel,
|
|
||||||
final AttachmentDimensions dimensions,
|
|
||||||
final String blurHash,
|
|
||||||
final boolean voiceNote
|
final boolean voiceNote
|
||||||
) throws AttachmentInvalidException {
|
) throws AttachmentInvalidException {
|
||||||
final var label = describe(attachment, inlineLabel);
|
|
||||||
try {
|
try {
|
||||||
final Pair<StreamDetails, Optional<String>> streamDetailsAndFileName;
|
|
||||||
if (isInline(attachment)) {
|
|
||||||
// Never open inline data as a file, whose errors could echo its payload. Check for the comma
|
|
||||||
// before parsing to avoid regex backtracking over long malformed values.
|
|
||||||
if (attachment.indexOf(',') < 0) {
|
|
||||||
throw new IOException("Invalid data URI");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
streamDetailsAndFileName = Utils.createStreamDetailsFromDataURI(attachment);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new IOException("Invalid data URI");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Reject local files that point into the signal-cli data directory
|
// Reject local files that point into the signal-cli data directory
|
||||||
final var canonical = new File(attachment).getCanonicalFile();
|
if (attachment != null && !attachment.startsWith("data:")) {
|
||||||
final var dataPath = this.dataPath.get().getCanonicalFile();
|
try {
|
||||||
|
final var file = new File(attachment);
|
||||||
|
final var canonical = file.getCanonicalFile();
|
||||||
|
final var dataPath = context.getAccount().getDataPath().getCanonicalFile();
|
||||||
if (canonical.toPath().startsWith(dataPath.toPath())) {
|
if (canonical.toPath().startsWith(dataPath.toPath())) {
|
||||||
throw new IOException("Attaching files from the signal-cli data directory is not allowed");
|
throw new AttachmentInvalidException(attachment,
|
||||||
|
new IOException("Attaching files from the signal-cli data directory is not allowed"));
|
||||||
}
|
}
|
||||||
streamDetailsAndFileName = Utils.createStreamDetails(attachment);
|
} catch (IOException e) {
|
||||||
|
throw new AttachmentInvalidException(attachment, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
|
||||||
final var streamDetails = streamDetailsAndFileName.first();
|
final var streamDetails = streamDetailsAndFileName.first();
|
||||||
final var uploadSpec = getResumableUploadSpec(streamDetails);
|
final var uploadSpec = getResumableUploadSpec(streamDetails);
|
||||||
|
|
||||||
return AttachmentUtils.createAttachmentStream(streamDetails,
|
return AttachmentUtils.createAttachmentStream(streamDetails,
|
||||||
streamDetailsAndFileName.second(),
|
streamDetailsAndFileName.second(),
|
||||||
voiceNote,
|
voiceNote,
|
||||||
dimensions,
|
|
||||||
blurHash,
|
|
||||||
uploadSpec);
|
uploadSpec);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw invalid(label, e);
|
throw new AttachmentInvalidException(attachment, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,89 +126,17 @@ public class AttachmentHelper {
|
|||||||
final var streamLength = streamDetails.getLength();
|
final var streamLength = streamDetails.getLength();
|
||||||
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
|
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
|
||||||
streamLength));
|
streamLength));
|
||||||
return uploader.getResumableUploadSpec(ciphertextLength);
|
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws AttachmentInvalidException {
|
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
|
||||||
return uploadAttachment(attachment, "inline attachment");
|
final var attachmentStream = getAttachmentStream(attachment, false);
|
||||||
|
return uploadAttachment(attachmentStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException {
|
public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException {
|
||||||
return uploader.upload(attachment);
|
var messageSender = dependencies.getMessageSender();
|
||||||
}
|
return messageSender.uploadAttachment(attachment);
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepares and uploads a single attachment, such as a quote thumbnail or a link preview image. Any
|
|
||||||
* failure is reported as {@link AttachmentInvalidException} with the given label for inline data.
|
|
||||||
*/
|
|
||||||
public SignalServiceAttachmentPointer uploadAttachment(
|
|
||||||
final String attachment,
|
|
||||||
final String inlineLabel
|
|
||||||
) throws AttachmentInvalidException {
|
|
||||||
try (final var attachmentStream = getAttachmentStream(attachment, inlineLabel, null, null, false)) {
|
|
||||||
return uploader.upload(attachmentStream);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw invalid(describe(attachment, inlineLabel), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads the full text of a message that is too long to be sent inline. Failures never include the text.
|
|
||||||
*/
|
|
||||||
public SignalServiceAttachmentPointer uploadLongTextAttachment(final byte[] messageBytes) throws AttachmentInvalidException {
|
|
||||||
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
|
|
||||||
MimeUtils.LONG_TEXT,
|
|
||||||
messageBytes.length);
|
|
||||||
try (final var textAttachment = createAttachmentStream(streamDetails)) {
|
|
||||||
return uploader.upload(textAttachment);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw invalid("long text attachment", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepares a sticker for sending. The sticker itself is uploaded later by the message sender.
|
|
||||||
*/
|
|
||||||
public SignalServiceAttachmentStream createStickerAttachmentStream(final StreamDetails streamDetails) throws AttachmentInvalidException {
|
|
||||||
try {
|
|
||||||
return createAttachmentStream(streamDetails);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw invalid("sticker", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private SignalServiceAttachmentStream createAttachmentStream(final StreamDetails streamDetails) throws IOException {
|
|
||||||
final var uploadSpec = getResumableUploadSpec(streamDetails);
|
|
||||||
return AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String describe(final String attachment, final String inlineLabel) {
|
|
||||||
return isInline(attachment) ? inlineLabel : attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
static AttachmentInvalidException invalid(final String label, final Exception e) {
|
|
||||||
final var message = e.getMessage() != null ? e.getMessage() : e.getClass().getName();
|
|
||||||
final var exception = new AttachmentInvalidException(label + ": " + message);
|
|
||||||
exception.initCause(e);
|
|
||||||
return exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isInline(final String attachment) {
|
|
||||||
return attachment != null && attachment.regionMatches(true, 0, "data:", 0, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String inlineAttachmentLabel(final int index) {
|
|
||||||
return "inline attachment #" + (index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void closeAll(final List<SignalServiceAttachmentStream> streams) {
|
|
||||||
for (final var stream : streams) {
|
|
||||||
try {
|
|
||||||
stream.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to close attachment stream, ignoring: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void downloadAttachment(final SignalServiceAttachment attachment) {
|
public void downloadAttachment(final SignalServiceAttachment attachment) {
|
||||||
@ -271,7 +158,7 @@ public class AttachmentHelper {
|
|||||||
try {
|
try {
|
||||||
attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream));
|
attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.warn("Failed to download attachment ({}), ignoring", pointer.getRemoteId(), e);
|
logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,24 +213,4 @@ public class AttachmentHelper {
|
|||||||
|
|
||||||
void handle(InputStream inputStream) throws IOException;
|
void handle(InputStream inputStream) throws IOException;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Uploader {
|
|
||||||
|
|
||||||
ResumableUploadSpec getResumableUploadSpec(long ciphertextLength) throws IOException;
|
|
||||||
|
|
||||||
SignalServiceAttachmentPointer upload(SignalServiceAttachmentStream stream) throws IOException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record DependenciesUploader(SignalDependencies dependencies) implements Uploader {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResumableUploadSpec getResumableUploadSpec(final long ciphertextLength) throws IOException {
|
|
||||||
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SignalServiceAttachmentPointer upload(final SignalServiceAttachmentStream stream) throws IOException {
|
|
||||||
return dependencies.getMessageSender().uploadAttachment(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -458,14 +458,10 @@ public class CallManager implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildConfig(CallState state) {
|
private String buildConfig(CallState state) {
|
||||||
return buildConfig(state, account.getDeviceId());
|
|
||||||
}
|
|
||||||
|
|
||||||
static String buildConfig(CallState state, int localDeviceId) {
|
|
||||||
var config = mapper.createObjectNode();
|
var config = mapper.createObjectNode();
|
||||||
config.put("call_id", Utils.callIdUnsigned(state.callId));
|
config.put("call_id", Utils.callIdUnsigned(state.callId));
|
||||||
config.put("is_outgoing", state.isOutgoing);
|
config.put("is_outgoing", state.isOutgoing);
|
||||||
config.put("local_device_id", localDeviceId);
|
config.put("local_device_id", 1);
|
||||||
return writeJson(config);
|
return writeJson(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -136,7 +136,7 @@ class GroupV2Helper {
|
|||||||
|
|
||||||
int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
|
int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
|
||||||
ByteString aciBytes = getSelfAci().toByteString();
|
ByteString aciBytes = getSelfAci().toByteString();
|
||||||
ByteString pniBytes = getSelfPni() == null ? null : getSelfPni().toByteString();
|
ByteString pniBytes = getSelfPni().toByteString();
|
||||||
for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
|
for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
|
||||||
if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
|
if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
|
||||||
return decryptedMember.joinedAtRevision;
|
return decryptedMember.joinedAtRevision;
|
||||||
@ -264,7 +264,7 @@ class GroupV2Helper {
|
|||||||
var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
|
var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
|
||||||
final var selfAci = getSelfAci();
|
final var selfAci = getSelfAci();
|
||||||
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
|
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
|
||||||
if (selfPendingMember.isEmpty() && getSelfPni() != null) {
|
if (selfPendingMember.isEmpty()) {
|
||||||
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
|
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -480,16 +480,15 @@ class GroupV2Helper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We need to accept the invite with the ACI or PNI that was used for the invitation
|
// We need to accept the invite with the ACI or PNI that was used for the invitation
|
||||||
final var account = context.getAccount();
|
final var selfAddress = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId);
|
||||||
final var selfServiceId = groupInfoV2.getPendingMemberAddresses()
|
final var selfServiceId = groupInfoV2.getPendingMemberAddresses()
|
||||||
.stream()
|
.stream()
|
||||||
.map(SignalServiceAddress::getServiceId)
|
.filter(s -> s.matches(selfAddress))
|
||||||
.filter(s -> s.equals(account.getAci()) || s.equals(account.getPni()))
|
|
||||||
.findFirst();
|
.findFirst();
|
||||||
if (selfServiceId.isEmpty()) {
|
if (selfServiceId.isEmpty()) {
|
||||||
throw new IOException("Cannot find service ID for self to accept invite");
|
throw new IOException("Cannot find service ID for self to accept invite");
|
||||||
}
|
}
|
||||||
final var serviceId = selfServiceId.get();
|
final var serviceId = selfServiceId.get().getServiceId();
|
||||||
|
|
||||||
final GroupChange.Actions.Builder change;
|
final GroupChange.Actions.Builder change;
|
||||||
if (serviceId instanceof ACI) {
|
if (serviceId instanceof ACI) {
|
||||||
@ -498,8 +497,7 @@ class GroupV2Helper {
|
|||||||
change = groupOperations.createAcceptPniInviteChange(profileKeyCredential);
|
change = groupOperations.createAcceptPniInviteChange(profileKeyCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The change is made by the account's ACI, also when it promotes a PNI invitation
|
change.sourceUserId(serviceId.toByteString());
|
||||||
change.sourceUserId(account.getAci().toByteString());
|
|
||||||
|
|
||||||
return commitChange(groupInfoV2, change);
|
return commitChange(groupInfoV2, change);
|
||||||
}
|
}
|
||||||
@ -636,18 +634,16 @@ class GroupV2Helper {
|
|||||||
final DecryptedGroupChange decryptedChange;
|
final DecryptedGroupChange decryptedChange;
|
||||||
final DecryptedGroup decryptedGroupState;
|
final DecryptedGroup decryptedGroupState;
|
||||||
|
|
||||||
var signedGroupChange = dependencies.getGroupsV2Api()
|
|
||||||
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
|
|
||||||
|
|
||||||
final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier();
|
|
||||||
try {
|
try {
|
||||||
decryptedChange = groupOperations.decryptChange(signedGroupChange.group_change,
|
decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
|
||||||
DecryptChangeVerificationMode.verify(groupId)).orElseThrow();
|
|
||||||
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
||||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||||
throw new IOException(e);
|
throw new IOException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var signedGroupChange = dependencies.getGroupsV2Api()
|
||||||
|
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
|
||||||
|
|
||||||
groupInfoV2.setGroup(decryptedGroupState);
|
groupInfoV2.setGroup(decryptedGroupState);
|
||||||
|
|
||||||
return new Pair<>(decryptedGroupState, signedGroupChange);
|
return new Pair<>(decryptedGroupState, signedGroupChange);
|
||||||
|
|||||||
@ -339,7 +339,7 @@ public final class IncomingMessageHandler {
|
|||||||
throw new AssertionError(e);
|
throw new AssertionError(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (destination.serviceId.equals(account.getPni())) {
|
if (account.getPni().equals(destination.serviceId)) {
|
||||||
account.getRecipientStore().markNeedsPniSignature(sender, true);
|
account.getRecipientStore().markNeedsPniSignature(sender, true);
|
||||||
} else if (account.getAci().equals(destination.serviceId)) {
|
} else if (account.getAci().equals(destination.serviceId)) {
|
||||||
account.getRecipientStore().markNeedsPniSignature(sender, false);
|
account.getRecipientStore().markNeedsPniSignature(sender, false);
|
||||||
@ -585,21 +585,6 @@ public final class IncomingMessageHandler {
|
|||||||
actions.addAll(dataResults.first());
|
actions.addAll(dataResults.first());
|
||||||
longTexts.putAll(dataResults.second());
|
longTexts.putAll(dataResults.second());
|
||||||
}
|
}
|
||||||
if (message.getEditMessage().isPresent()) {
|
|
||||||
final var dataResults = handleSignalServiceDataMessage(message.getEditMessage()
|
|
||||||
.get()
|
|
||||||
.getDataMessage(),
|
|
||||||
true,
|
|
||||||
sender,
|
|
||||||
destination == null
|
|
||||||
? null
|
|
||||||
: new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
|
|
||||||
destination.getServiceId(),
|
|
||||||
0),
|
|
||||||
receiveConfig);
|
|
||||||
actions.addAll(dataResults.first());
|
|
||||||
longTexts.putAll(dataResults.second());
|
|
||||||
}
|
|
||||||
if (message.getStoryMessage().isPresent()) {
|
if (message.getStoryMessage().isPresent()) {
|
||||||
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
|
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
|
||||||
sender.recipientId(),
|
sender.recipientId(),
|
||||||
@ -1139,7 +1124,7 @@ public final class IncomingMessageHandler {
|
|||||||
if (destination == null || destination.isUnknown()) {
|
if (destination == null || destination.isUnknown()) {
|
||||||
throw new InvalidMessageException("Missing destination");
|
throw new InvalidMessageException("Missing destination");
|
||||||
}
|
}
|
||||||
if (!destination.equals(account.getAci()) && !destination.equals(account.getPni())) {
|
if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
|
||||||
throw new InvalidMessageException("Message not intended for this account");
|
throw new InvalidMessageException("Message not intended for this account");
|
||||||
}
|
}
|
||||||
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
|
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
|
||||||
|
|||||||
@ -365,7 +365,7 @@ public final class ProfileHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (recipientId.equals(account.getSelfRecipientId())) {
|
if (recipientId.equals(account.getSelfRecipientId())) {
|
||||||
final var isUnrestricted = encryptedProfile.getUnrestrictedUnidentifiedAccess();
|
final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess();
|
||||||
if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
|
if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
|
||||||
account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
|
account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,8 +42,8 @@ public class ReceiveHelper {
|
|||||||
|
|
||||||
private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false);
|
private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false);
|
||||||
private boolean hasCaughtUpWithOldMessages = false;
|
private boolean hasCaughtUpWithOldMessages = false;
|
||||||
private volatile boolean isWaitingForMessage = false;
|
private boolean isWaitingForMessage = false;
|
||||||
private volatile boolean shouldStop = false;
|
private boolean shouldStop = false;
|
||||||
private Callable authenticationFailureListener;
|
private Callable authenticationFailureListener;
|
||||||
private Callable caughtUpWithOldMessagesListener;
|
private Callable caughtUpWithOldMessagesListener;
|
||||||
|
|
||||||
@ -71,15 +71,6 @@ public class ReceiveHelper {
|
|||||||
return isWaitingForMessage;
|
return isWaitingForMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Must only be called right before a new receive loop is started, while no other receive loop is running.
|
|
||||||
* Ending a receive loop doesn't clear the stop request, so a loop that was asked to stop can't continue
|
|
||||||
* receiving, e.g. by retrying in receiveMessagesContinuously after an IOException.
|
|
||||||
*/
|
|
||||||
public void clearStopRequest() {
|
|
||||||
this.shouldStop = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void receiveMessagesContinuously(Manager.ReceiveMessageHandler handler) {
|
public void receiveMessagesContinuously(Manager.ReceiveMessageHandler handler) {
|
||||||
while (!shouldStop) {
|
while (!shouldStop) {
|
||||||
try {
|
try {
|
||||||
@ -120,6 +111,7 @@ public class ReceiveHelper {
|
|||||||
signalWebSocket.removeKeepAliveToken("receive");
|
signalWebSocket.removeKeepAliveToken("receive");
|
||||||
signalWebSocket.disconnect();
|
signalWebSocket.disconnect();
|
||||||
webSocketStateDisposable.dispose();
|
webSocketStateDisposable.dispose();
|
||||||
|
shouldStop = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -555,8 +555,7 @@ public class SendHelper {
|
|||||||
recipients,
|
recipients,
|
||||||
unidentifiedAccess,
|
unidentifiedAccess,
|
||||||
groupSendEndorsements,
|
groupSendEndorsements,
|
||||||
message,
|
message),
|
||||||
null),
|
|
||||||
recipientIds,
|
recipientIds,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
false);
|
false);
|
||||||
@ -887,8 +886,7 @@ public class SendHelper {
|
|||||||
|
|
||||||
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
|
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
|
||||||
try {
|
try {
|
||||||
final boolean includePniSignature = account.getPni() != null && account.getRecipientStore()
|
final boolean includePniSignature = account.getRecipientStore().needsPniSignature(recipientId);
|
||||||
.needsPniSignature(recipientId);
|
|
||||||
try {
|
try {
|
||||||
return s.send(messageSender,
|
return s.send(messageSender,
|
||||||
address,
|
address,
|
||||||
|
|||||||
@ -492,7 +492,7 @@ public class StorageHelper {
|
|||||||
final var storageId = newStickerPackStorageIds.get(stickerPack.packId());
|
final var storageId = newStickerPackStorageIds.get(stickerPack.packId());
|
||||||
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
||||||
newStorageRecords.add(new SignalStorageRecord(storageId,
|
newStorageRecords.add(new SignalStorageRecord(storageId,
|
||||||
new StorageRecord.Builder().installedStickerPack(record).build()));
|
new StorageRecord.Builder().stickerPack(record).build()));
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.commit();
|
connection.commit();
|
||||||
@ -680,8 +680,7 @@ public class StorageHelper {
|
|||||||
throw new AssertionError("Missing local sticker pack model for storage id: " + storageId);
|
throw new AssertionError("Missing local sticker pack model for storage id: " + storageId);
|
||||||
}
|
}
|
||||||
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
final var record = StorageSyncModels.localToRemoteRecord(stickerPack);
|
||||||
yield new SignalStorageRecord(storageId,
|
yield new SignalStorageRecord(storageId, new StorageRecord.Builder().stickerPack(record).build());
|
||||||
new StorageRecord.Builder().installedStickerPack(record).build());
|
|
||||||
}
|
}
|
||||||
case null, default -> {
|
case null, default -> {
|
||||||
throw new AssertionError("Got unknown local storage record type: " + storageId);
|
throw new AssertionError("Got unknown local storage record type: " + storageId);
|
||||||
@ -778,9 +777,9 @@ public class StorageHelper {
|
|||||||
logger.debug("Reading record {} of type contact", record.getId());
|
logger.debug("Reading record {} of type contact", record.getId());
|
||||||
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
|
contactRecordProcessor.process(StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
|
||||||
record.getId()));
|
record.getId()));
|
||||||
} else if (record.getProto().installedStickerPack != null) {
|
} else if (record.getProto().stickerPack != null) {
|
||||||
logger.debug("Reading record {} of type stickerPack", record.getId());
|
logger.debug("Reading record {} of type stickerPack", record.getId());
|
||||||
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalInstalledStickerPackRecord(record.getProto().installedStickerPack,
|
stickerPackRecordProcessor.process(StorageRecordConvertersKt.toSignalStickerPackRecord(record.getProto().stickerPack,
|
||||||
record.getId()));
|
record.getId()));
|
||||||
} else {
|
} else {
|
||||||
unknownRecords.add(record.getId());
|
unknownRecords.add(record.getId());
|
||||||
|
|||||||
@ -90,6 +90,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientId;
|
|||||||
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
|
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
|
||||||
import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
|
import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
|
||||||
import org.asamk.signal.manager.storage.stickers.StickerPack;
|
import org.asamk.signal.manager.storage.stickers.StickerPack;
|
||||||
|
import org.asamk.signal.manager.util.AttachmentUtils;
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
import org.asamk.signal.manager.util.KeyUtils;
|
||||||
import org.asamk.signal.manager.util.MimeUtils;
|
import org.asamk.signal.manager.util.MimeUtils;
|
||||||
import org.asamk.signal.manager.util.PhoneNumberFormatter;
|
import org.asamk.signal.manager.util.PhoneNumberFormatter;
|
||||||
@ -123,8 +124,10 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
|
|||||||
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
|
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
|
||||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
|
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
|
||||||
|
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||||
import org.whispersystems.signalservice.internal.util.Util;
|
import org.whispersystems.signalservice.internal.util.Util;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
@ -174,9 +177,6 @@ public class ManagerImpl implements Manager {
|
|||||||
private boolean isReceivingSynchronous;
|
private boolean isReceivingSynchronous;
|
||||||
private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
|
private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
|
||||||
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
|
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
|
||||||
// The last handler while its removal stops the receive thread: it still gets the envelope the thread is
|
|
||||||
// handling, but doesn't keep the thread receiving.
|
|
||||||
private final Set<ReceiveMessageHandler> drainingHandlers = new HashSet<>();
|
|
||||||
private final List<Runnable> closedListeners = new ArrayList<>();
|
private final List<Runnable> closedListeners = new ArrayList<>();
|
||||||
private final List<Runnable> addressChangedListeners = new ArrayList<>();
|
private final List<Runnable> addressChangedListeners = new ArrayList<>();
|
||||||
private final CompositeDisposable disposable = new CompositeDisposable();
|
private final CompositeDisposable disposable = new CompositeDisposable();
|
||||||
@ -953,8 +953,15 @@ public class ManagerImpl implements Manager {
|
|||||||
final var remainder = result.getSecond();
|
final var remainder = result.getSecond();
|
||||||
if (remainder != null) {
|
if (remainder != null) {
|
||||||
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
|
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
|
||||||
|
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
|
||||||
|
MimeUtils.LONG_TEXT,
|
||||||
|
messageBytes.length);
|
||||||
|
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
|
||||||
|
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
|
||||||
|
Optional.empty(),
|
||||||
|
uploadSpec);
|
||||||
messageBuilder.withBody(trimmed);
|
messageBuilder.withBody(trimmed);
|
||||||
additionalAttachments.add(context.getAttachmentHelper().uploadLongTextAttachment(messageBytes));
|
additionalAttachments.add(context.getAttachmentHelper().uploadAttachment(textAttachment));
|
||||||
} else {
|
} else {
|
||||||
messageBuilder.withBody(message.messageText());
|
messageBuilder.withBody(message.messageText());
|
||||||
}
|
}
|
||||||
@ -963,10 +970,7 @@ public class ManagerImpl implements Manager {
|
|||||||
}
|
}
|
||||||
if (!message.attachments().isEmpty()) {
|
if (!message.attachments().isEmpty()) {
|
||||||
final var uploadedAttachments = context.getAttachmentHelper()
|
final var uploadedAttachments = context.getAttachmentHelper()
|
||||||
.uploadAttachments(message.attachments(),
|
.uploadAttachments(message.attachments(), message.voiceNote());
|
||||||
message.attachmentDimensions(),
|
|
||||||
message.attachmentBlurHashes(),
|
|
||||||
message.voiceNote());
|
|
||||||
if (!additionalAttachments.isEmpty()) {
|
if (!additionalAttachments.isEmpty()) {
|
||||||
additionalAttachments.addAll(uploadedAttachments);
|
additionalAttachments.addAll(uploadedAttachments);
|
||||||
messageBuilder.withAttachments(additionalAttachments);
|
messageBuilder.withAttachments(additionalAttachments);
|
||||||
@ -987,12 +991,9 @@ public class ManagerImpl implements Manager {
|
|||||||
final var quote = message.quote().get();
|
final var quote = message.quote().get();
|
||||||
final var quotedAttachments = new ArrayList<SignalServiceDataMessage.Quote.QuotedAttachment>();
|
final var quotedAttachments = new ArrayList<SignalServiceDataMessage.Quote.QuotedAttachment>();
|
||||||
for (final var a : quote.attachments()) {
|
for (final var a : quote.attachments()) {
|
||||||
final var label = "quote thumbnail #" + (quotedAttachments.size() + 1);
|
|
||||||
final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(),
|
final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(),
|
||||||
a.filename(),
|
a.filename(),
|
||||||
a.preview() == null
|
a.preview() == null ? null : context.getAttachmentHelper().uploadAttachment(a.preview()));
|
||||||
? null
|
|
||||||
: context.getAttachmentHelper().uploadAttachment(a.preview(), label));
|
|
||||||
quotedAttachments.add(quotedAttachment);
|
quotedAttachments.add(quotedAttachment);
|
||||||
}
|
}
|
||||||
messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
|
messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
|
||||||
@ -1023,7 +1024,10 @@ public class ManagerImpl implements Manager {
|
|||||||
if (streamDetails == null) {
|
if (streamDetails == null) {
|
||||||
throw new InvalidStickerException("Missing local sticker file");
|
throw new InvalidStickerException("Missing local sticker file");
|
||||||
}
|
}
|
||||||
final var stickerAttachment = context.getAttachmentHelper().createStickerAttachmentStream(streamDetails);
|
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
|
||||||
|
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
|
||||||
|
Optional.empty(),
|
||||||
|
uploadSpec);
|
||||||
messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
|
messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
|
||||||
stickerPack.packKey(),
|
stickerPack.packKey(),
|
||||||
stickerId,
|
stickerId,
|
||||||
@ -1033,9 +1037,8 @@ public class ManagerImpl implements Manager {
|
|||||||
if (!message.previews().isEmpty()) {
|
if (!message.previews().isEmpty()) {
|
||||||
final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
|
final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
|
||||||
for (final var p : message.previews()) {
|
for (final var p : message.previews()) {
|
||||||
final var label = "link preview image #" + (previews.size() + 1);
|
|
||||||
final var image = p.image().isPresent() ? context.getAttachmentHelper()
|
final var image = p.image().isPresent() ? context.getAttachmentHelper()
|
||||||
.uploadAttachment(p.image().get(), label) : null;
|
.uploadAttachment(p.image().get()) : null;
|
||||||
previews.add(new SignalServicePreview(p.url(),
|
previews.add(new SignalServicePreview(p.url(),
|
||||||
p.title(),
|
p.title(),
|
||||||
p.description(),
|
p.description(),
|
||||||
@ -1513,18 +1516,15 @@ public class ManagerImpl implements Manager {
|
|||||||
private static final AtomicInteger threadNumber = new AtomicInteger(0);
|
private static final AtomicInteger threadNumber = new AtomicInteger(0);
|
||||||
|
|
||||||
private void startReceiveThreadIfRequired() {
|
private void startReceiveThreadIfRequired() {
|
||||||
if (isReceiveThreadAlive() || isReceivingSynchronous) {
|
if (receiveThread != null || isReceivingSynchronous) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.getReceiveHelper().clearStopRequest();
|
|
||||||
receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
|
receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
|
||||||
logger.debug("Starting receiving messages");
|
logger.debug("Starting receiving messages");
|
||||||
context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
|
context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
|
||||||
logger.debug("Finished receiving messages");
|
logger.debug("Finished receiving messages");
|
||||||
synchronized (messageHandlers) {
|
synchronized (messageHandlers) {
|
||||||
if (receiveThread == Thread.currentThread()) {
|
|
||||||
receiveThread = null;
|
receiveThread = null;
|
||||||
}
|
|
||||||
|
|
||||||
// Check if in the meantime another handler has been registered
|
// Check if in the meantime another handler has been registered
|
||||||
if (!messageHandlers.isEmpty()) {
|
if (!messageHandlers.isEmpty()) {
|
||||||
@ -1535,16 +1535,9 @@ public class ManagerImpl implements Manager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isReceiveThreadAlive() {
|
|
||||||
// The receive thread clears receiveThread itself when it has finished, so it stays set while the thread is
|
|
||||||
// still stopping and no second receive thread can be started in the meantime.
|
|
||||||
// A receive thread that died from an uncaught exception never clears it.
|
|
||||||
return receiveThread != null && receiveThread.isAlive();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) {
|
private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) {
|
||||||
synchronized (messageHandlers) {
|
synchronized (messageHandlers) {
|
||||||
Stream.of(messageHandlers, weakHandlers, drainingHandlers).flatMap(Set::stream).forEach(h -> {
|
Stream.concat(messageHandlers.stream(), weakHandlers.stream()).forEach(h -> {
|
||||||
try {
|
try {
|
||||||
h.handleMessage(envelope, e);
|
h.handleMessage(envelope, e);
|
||||||
} catch (Throwable ex) {
|
} catch (Throwable ex) {
|
||||||
@ -1559,26 +1552,15 @@ public class ManagerImpl implements Manager {
|
|||||||
final Thread thread;
|
final Thread thread;
|
||||||
synchronized (messageHandlers) {
|
synchronized (messageHandlers) {
|
||||||
weakHandlers.remove(handler);
|
weakHandlers.remove(handler);
|
||||||
final var removed = messageHandlers.remove(handler);
|
messageHandlers.remove(handler);
|
||||||
if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) {
|
if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
thread = receiveThread;
|
thread = receiveThread;
|
||||||
// The receive thread may be handling an envelope that is already cached and acknowledged to the server,
|
receiveThread = null;
|
||||||
// and the cache drops it once it's handled. So the last handler keeps getting envelopes until the thread
|
|
||||||
// has stopped, otherwise that envelope would reach no handler and be lost.
|
|
||||||
if (removed) {
|
|
||||||
drainingHandlers.add(handler);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
stopReceiveThread(thread);
|
stopReceiveThread(thread);
|
||||||
} finally {
|
|
||||||
synchronized (messageHandlers) {
|
|
||||||
drainingHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopReceiveThread(final Thread thread) {
|
private void stopReceiveThread(final Thread thread) {
|
||||||
@ -1631,12 +1613,11 @@ public class ManagerImpl implements Manager {
|
|||||||
ReceiveMessageHandler handler
|
ReceiveMessageHandler handler
|
||||||
) throws IOException, AlreadyReceivingException {
|
) throws IOException, AlreadyReceivingException {
|
||||||
synchronized (messageHandlers) {
|
synchronized (messageHandlers) {
|
||||||
if (isReceiving() || isReceiveThreadAlive()) {
|
if (isReceiving()) {
|
||||||
throw new AlreadyReceivingException("Already receiving message.");
|
throw new AlreadyReceivingException("Already receiving message.");
|
||||||
}
|
}
|
||||||
isReceivingSynchronous = true;
|
isReceivingSynchronous = true;
|
||||||
receiveThread = Thread.currentThread();
|
receiveThread = Thread.currentThread();
|
||||||
context.getReceiveHelper().clearStopRequest();
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
context.getReceiveHelper().receiveMessages(timeout, maxMessages, (envelope, e) -> {
|
context.getReceiveHelper().receiveMessages(timeout, maxMessages, (envelope, e) -> {
|
||||||
@ -2061,6 +2042,7 @@ public class ManagerImpl implements Manager {
|
|||||||
weakHandlers.clear();
|
weakHandlers.clear();
|
||||||
messageHandlers.clear();
|
messageHandlers.clear();
|
||||||
thread = receiveThread;
|
thread = receiveThread;
|
||||||
|
receiveThread = null;
|
||||||
}
|
}
|
||||||
if (thread != null) {
|
if (thread != null) {
|
||||||
stopReceiveThread(thread);
|
stopReceiveThread(thread);
|
||||||
|
|||||||
@ -39,6 +39,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
|
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAccountNumbers() {
|
||||||
|
synchronized (managers) {
|
||||||
|
return managers.stream().map(Manager::getSelfNumber).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Manager> getManagers() {
|
public List<Manager> getManagers() {
|
||||||
synchronized (managers) {
|
synchronized (managers) {
|
||||||
@ -68,13 +75,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
|
|
||||||
synchronized (onManagerAddedHandlers) {
|
|
||||||
onManagerAddedHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeManager(final Manager m) {
|
void removeManager(final Manager m) {
|
||||||
synchronized (managers) {
|
synchronized (managers) {
|
||||||
if (!managers.remove(m)) {
|
if (!managers.remove(m)) {
|
||||||
@ -95,13 +95,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
|
||||||
synchronized (onManagerRemovedHandlers) {
|
|
||||||
onManagerRemovedHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Manager getManager(final String identifier) {
|
public Manager getManager(final String identifier) {
|
||||||
synchronized (managers) {
|
synchronized (managers) {
|
||||||
@ -128,7 +121,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
} else {
|
} else {
|
||||||
// Phone number — check already loaded managers
|
// Phone number — check already loaded managers
|
||||||
var existing = managers.stream()
|
var existing = managers.stream()
|
||||||
.filter(m -> identifier.equals(m.getSelfNumber()))
|
.filter(m -> m.getSelfNumber().equals(identifier))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
|||||||
@ -19,8 +19,8 @@ package org.asamk.signal.manager.internal;
|
|||||||
import org.asamk.signal.manager.Manager;
|
import org.asamk.signal.manager.Manager;
|
||||||
import org.asamk.signal.manager.ProvisioningManager;
|
import org.asamk.signal.manager.ProvisioningManager;
|
||||||
import org.asamk.signal.manager.Settings;
|
import org.asamk.signal.manager.Settings;
|
||||||
import org.asamk.signal.manager.api.BadRequestException;
|
|
||||||
import org.asamk.signal.manager.api.UserAlreadyExistsException;
|
import org.asamk.signal.manager.api.UserAlreadyExistsException;
|
||||||
|
import org.asamk.signal.manager.config.ServiceConfig;
|
||||||
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
|
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
import org.asamk.signal.manager.storage.SignalAccount;
|
||||||
import org.asamk.signal.manager.storage.accounts.AccountsStore;
|
import org.asamk.signal.manager.storage.accounts.AccountsStore;
|
||||||
@ -34,15 +34,13 @@ import org.signal.libsignal.protocol.IdentityKey;
|
|||||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
||||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||||
import org.signal.network.api.RegistrationApiV2;
|
|
||||||
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
|
|
||||||
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
|
|
||||||
import org.signal.network.rest.SignalRestClient;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.account.PreKeyCollection;
|
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||||
|
import org.whispersystems.signalservice.api.account.DeviceAttributes;
|
||||||
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
|
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
|
||||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||||
|
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||||
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
|
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
|
||||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
||||||
@ -69,7 +67,7 @@ import kotlinx.coroutines.BuildersKt;
|
|||||||
import kotlinx.coroutines.CoroutineScope;
|
import kotlinx.coroutines.CoroutineScope;
|
||||||
|
|
||||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||||
|
|
||||||
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||||
|
|
||||||
@ -150,20 +148,18 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
|||||||
|
|
||||||
var number = msg.number;
|
var number = msg.number;
|
||||||
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
|
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
|
||||||
var pni = parsePni(msg);
|
var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary);
|
||||||
var identifier = number != null ? number : aci.toString();
|
|
||||||
|
|
||||||
logger.info("Received link information from {}, linking in progress ...", identifier);
|
logger.info("Received link information from {}, linking in progress ...", number);
|
||||||
|
|
||||||
var accountPath = accountsStore.getPathByAci(aci);
|
var accountPath = accountsStore.getPathByAci(aci);
|
||||||
if (accountPath == null && number != null) {
|
if (accountPath == null) {
|
||||||
accountPath = accountsStore.getPathByNumber(number);
|
accountPath = accountsStore.getPathByNumber(number);
|
||||||
}
|
}
|
||||||
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
|
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
|
||||||
accountPath);
|
accountPath);
|
||||||
if (accountExists && !canRelinkExistingAccount(accountPath)) {
|
if (accountExists && !canRelinkExistingAccount(accountPath)) {
|
||||||
throw new UserAlreadyExistsException(identifier,
|
throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
|
||||||
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
|
|
||||||
}
|
}
|
||||||
if (accountPath == null) {
|
if (accountPath == null) {
|
||||||
accountPath = accountsStore.addAccount(number, aci);
|
accountPath = accountsStore.addAccount(number, aci);
|
||||||
@ -177,9 +173,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
|||||||
try {
|
try {
|
||||||
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
|
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
|
||||||
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
|
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
|
||||||
pniIdentity = pni == null
|
pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
|
||||||
? null
|
|
||||||
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
|
|
||||||
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
|
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
|
||||||
profileKey = msg.profileKey == null
|
profileKey = msg.profileKey == null
|
||||||
? KeyUtils.createProfileKey()
|
? KeyUtils.createProfileKey()
|
||||||
@ -228,19 +222,28 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||||
final var pniPreKeys = pni == null
|
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||||
? null
|
|
||||||
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
|
||||||
|
|
||||||
logger.debug("Finishing new device registration");
|
logger.debug("Finishing new device registration");
|
||||||
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
|
final var attrs = account.getAccountAttributes(null);
|
||||||
userAgent);
|
final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
|
||||||
final var registrationApi = new RegistrationApiV2(restClient, false);
|
attrs.getRegistrationId(),
|
||||||
final var deviceId = registerLinkedDevice(registrationApi,
|
attrs.getPniRegistrationId(),
|
||||||
account,
|
attrs.getName(),
|
||||||
msg.provisioningCode,
|
attrs.getCapabilities());
|
||||||
aciPreKeys,
|
final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials(
|
||||||
pniPreKeys);
|
serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
number,
|
||||||
|
SignalServiceAddress.DEFAULT_DEVICE_ID,
|
||||||
|
password,
|
||||||
|
userAgent,
|
||||||
|
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
|
||||||
|
ServiceConfig.GROUP_MAX_SIZE);
|
||||||
|
final var registerResponse = handleResponseException(unauthAccountManager.getRegistrationApi()
|
||||||
|
.registerAsSecondaryDevice(msg.provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, null));
|
||||||
|
final var deviceId = Integer.parseInt(registerResponse.getDeviceId());
|
||||||
|
|
||||||
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
|
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
|
||||||
linkingFinished = true;
|
linkingFinished = true;
|
||||||
@ -274,7 +277,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
|||||||
newManagerListener.accept(m);
|
newManagerListener.accept(m);
|
||||||
m = null;
|
m = null;
|
||||||
}
|
}
|
||||||
return identifier;
|
return number;
|
||||||
} finally {
|
} finally {
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
m.close();
|
m.close();
|
||||||
@ -293,71 +296,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static PNI parsePni(final ProvisionMessage message) throws IOException {
|
|
||||||
if (message.number != null) {
|
|
||||||
return PNI.parseOrThrow(message.pni, message.pniBinary);
|
|
||||||
}
|
|
||||||
if (message.pni != null
|
|
||||||
|| message.pniBinary != null
|
|
||||||
|| message.pniIdentityKeyPublic != null
|
|
||||||
|| message.pniIdentityKeyPrivate != null) {
|
|
||||||
throw new IOException("Provisioning message has PNI material without a phone number");
|
|
||||||
}
|
|
||||||
if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) {
|
|
||||||
throw new IOException("Numberless provisioning message is missing the group auth credential salt");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int registerLinkedDevice(
|
|
||||||
final RegistrationApiV2 registrationApi,
|
|
||||||
final SignalAccount account,
|
|
||||||
final String provisioningCode,
|
|
||||||
final PreKeyCollection aciPreKeys,
|
|
||||||
final PreKeyCollection pniPreKeys
|
|
||||||
) throws IOException {
|
|
||||||
final var attrs = account.getAccountAttributesV2();
|
|
||||||
final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(),
|
|
||||||
attrs.getRegistrationId(),
|
|
||||||
attrs.getPniRegistrationId(),
|
|
||||||
attrs.getName(),
|
|
||||||
attrs.getCapabilities());
|
|
||||||
try {
|
|
||||||
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
|
|
||||||
account.getAci(),
|
|
||||||
account.getPassword(),
|
|
||||||
provisioningCode,
|
|
||||||
deviceAttributes,
|
|
||||||
toRegistrationPreKeys(aciPreKeys),
|
|
||||||
toRegistrationPreKeys(pniPreKeys),
|
|
||||||
null,
|
|
||||||
cont));
|
|
||||||
return result.getDeviceId();
|
|
||||||
} catch (BadRequestException e) {
|
|
||||||
throw switch (e.getError()) {
|
|
||||||
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
|
|
||||||
new AuthorizationFailedException(403, "Device verification failed");
|
|
||||||
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
|
|
||||||
new IOException("Linked device is missing a required account capability");
|
|
||||||
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
|
|
||||||
new IOException("Account has reached its linked device limit");
|
|
||||||
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
|
|
||||||
new IOException("Signal rejected the device linking request");
|
|
||||||
case RegisterAsLinkedDeviceError.RateLimited ignored ->
|
|
||||||
new IOException("Device linking rate limited; try again later");
|
|
||||||
default -> new IOException("Unexpected device linking response");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
|
|
||||||
return preKeys == null
|
|
||||||
? null
|
|
||||||
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
|
|
||||||
preKeys.getSignedPreKey(),
|
|
||||||
preKeys.getLastResortKyberPreKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws IOException {
|
public void close() throws IOException {
|
||||||
socketHandle.close();
|
socketHandle.close();
|
||||||
|
|||||||
@ -18,14 +18,12 @@ package org.asamk.signal.manager.internal;
|
|||||||
|
|
||||||
import org.asamk.signal.manager.Manager;
|
import org.asamk.signal.manager.Manager;
|
||||||
import org.asamk.signal.manager.RegistrationManager;
|
import org.asamk.signal.manager.RegistrationManager;
|
||||||
import org.asamk.signal.manager.api.BadRequestException;
|
|
||||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||||
import org.asamk.signal.manager.api.PinLockedException;
|
import org.asamk.signal.manager.api.PinLockedException;
|
||||||
import org.asamk.signal.manager.api.RateLimitException;
|
import org.asamk.signal.manager.api.RateLimitException;
|
||||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
|
||||||
import org.asamk.signal.manager.api.UpdateProfile;
|
import org.asamk.signal.manager.api.UpdateProfile;
|
||||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
import org.asamk.signal.manager.config.ServiceConfig;
|
||||||
@ -35,14 +33,10 @@ import org.asamk.signal.manager.helper.PinHelper;
|
|||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
import org.asamk.signal.manager.storage.SignalAccount;
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
import org.asamk.signal.manager.util.KeyUtils;
|
||||||
import org.asamk.signal.manager.util.NumberVerificationUtils;
|
import org.asamk.signal.manager.util.NumberVerificationUtils;
|
||||||
import org.signal.core.models.AccountEntropyPool;
|
|
||||||
import org.signal.core.models.MasterKey;
|
import org.signal.core.models.MasterKey;
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
import org.signal.core.models.ServiceId.ACI;
|
||||||
import org.signal.core.models.ServiceId.PNI;
|
import org.signal.core.models.ServiceId.PNI;
|
||||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||||
import org.signal.network.api.RegistrationApiV2;
|
|
||||||
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
|
|
||||||
import org.signal.network.rest.SignalRestClient;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||||
@ -56,25 +50,15 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
|
|||||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
|
|
||||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
|
||||||
|
|
||||||
public class RegistrationManagerImpl implements RegistrationManager {
|
public class RegistrationManagerImpl implements RegistrationManager {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
|
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
|
||||||
|
|
||||||
private static final class RecoveryRequestFailedException extends IOException {
|
|
||||||
|
|
||||||
private RecoveryRequestFailedException(final String message, final Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private SignalAccount account;
|
private SignalAccount account;
|
||||||
private final PathConfig pathConfig;
|
private final PathConfig pathConfig;
|
||||||
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
||||||
@ -123,7 +107,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
|||||||
boolean voiceVerification,
|
boolean voiceVerification,
|
||||||
String captcha,
|
String captcha,
|
||||||
final boolean forceRegister
|
final boolean forceRegister
|
||||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException {
|
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
|
||||||
if (account.isRegistered()
|
if (account.isRegistered()
|
||||||
&& account.getServiceEnvironment() != null
|
&& account.getServiceEnvironment() != null
|
||||||
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
|
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
|
||||||
@ -145,13 +129,6 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
|||||||
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
|
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.getNumber() == null) {
|
|
||||||
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
|
|
||||||
}
|
|
||||||
|
|
||||||
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
|
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
|
||||||
logger.trace("Creating verification session");
|
logger.trace("Creating verification session");
|
||||||
@ -207,109 +184,6 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
|||||||
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
|
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerWithRecoveryKey(
|
|
||||||
final String recoveryKey,
|
|
||||||
final boolean forceRegister,
|
|
||||||
final Integer totp
|
|
||||||
) throws IOException, RateLimitException, TwoFactorRequired {
|
|
||||||
if (account.getAci() == null) {
|
|
||||||
throw new IOException("Recovery-key registration requires an ACI account identifier");
|
|
||||||
}
|
|
||||||
if (account.isRegistered() && !forceRegister) {
|
|
||||||
throw new IOException("Account is already registered; use --reregister to register it again");
|
|
||||||
}
|
|
||||||
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
|
|
||||||
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
|
|
||||||
throw new IOException("Invalid recovery key");
|
|
||||||
}
|
|
||||||
recoverAccount(totp, false, accountEntropyPool);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean attemptRecoverAccount(
|
|
||||||
final Integer totp
|
|
||||||
) throws IOException, RateLimitException, TwoFactorRequired {
|
|
||||||
final var accountEntropyPool = account.getAccountEntropyPool();
|
|
||||||
try {
|
|
||||||
recoverAccount(totp, false, accountEntropyPool);
|
|
||||||
logger.info("Reregistered existing account using its ACI and Account Entropy Pool.");
|
|
||||||
return true;
|
|
||||||
} catch (TwoFactorRequired | RateLimitException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (RecoveryRequestFailedException e) {
|
|
||||||
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void recoverAccount(
|
|
||||||
final Integer totp,
|
|
||||||
final boolean includeRegistrationLock,
|
|
||||||
final AccountEntropyPool accountEntropyPool
|
|
||||||
) throws IOException, RateLimitException, TwoFactorRequired {
|
|
||||||
if (account.getPniIdentityKeyPair() == null) {
|
|
||||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
|
||||||
}
|
|
||||||
|
|
||||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
|
||||||
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
|
||||||
final var masterKey = accountEntropyPool.deriveMasterKey();
|
|
||||||
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
|
|
||||||
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
|
|
||||||
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
|
|
||||||
final var registrationApi = new RegistrationApiV2(restClient, true);
|
|
||||||
|
|
||||||
final RegistrationApiV2.RegisterAccountResponse response;
|
|
||||||
try {
|
|
||||||
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
|
|
||||||
account.getPassword(),
|
|
||||||
null,
|
|
||||||
recoveryPassword,
|
|
||||||
null,
|
|
||||||
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
|
|
||||||
toRegistrationPreKeys(aciPreKeys),
|
|
||||||
toRegistrationPreKeys(pniPreKeys),
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
account.getAci(),
|
|
||||||
totp,
|
|
||||||
null,
|
|
||||||
cont));
|
|
||||||
} catch (BadRequestException e) {
|
|
||||||
switch (e.getError()) {
|
|
||||||
case RegisterAccountError.RegistrationLock ignored -> {
|
|
||||||
if (includeRegistrationLock) {
|
|
||||||
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
|
|
||||||
}
|
|
||||||
recoverAccount(totp, true, accountEntropyPool);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case RegisterAccountError.TwoFactorRequired ignored -> throw new TwoFactorRequired();
|
|
||||||
case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored ->
|
|
||||||
throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e);
|
|
||||||
case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null);
|
|
||||||
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
|
|
||||||
throw new IOException("signal-cli is too old to register this account", e);
|
|
||||||
default -> throw new IOException("Signal rejected recovery-key registration", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final var aci = ACI.parseOrThrow(response.getAci());
|
|
||||||
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
|
|
||||||
final var authCredentialSalt = response.getAuthCredentialSalt() == null
|
|
||||||
? null
|
|
||||||
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
|
|
||||||
account.finishRecoveryRegistration(aci,
|
|
||||||
pni,
|
|
||||||
response.getE164(),
|
|
||||||
accountEntropyPool,
|
|
||||||
authCredentialSalt,
|
|
||||||
aciPreKeys,
|
|
||||||
pni == null ? null : pniPreKeys);
|
|
||||||
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
|
|
||||||
finishManagerRegistration(response.getStorageCapable());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteLocalAccountData() throws IOException {
|
public void deleteLocalAccountData() throws IOException {
|
||||||
account.deleteAccountData();
|
account.deleteAccountData();
|
||||||
@ -417,17 +291,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
|||||||
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
|
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
|
||||||
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
|
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
|
||||||
|
|
||||||
finishManagerRegistration(response.getStorageCapable());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
|
|
||||||
ManagerImpl m = null;
|
ManagerImpl m = null;
|
||||||
try {
|
try {
|
||||||
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
|
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
|
||||||
account = null;
|
account = null;
|
||||||
|
|
||||||
m.refreshPreKeys();
|
m.refreshPreKeys();
|
||||||
if (storageCapable) {
|
if (response.isStorageCapable()) {
|
||||||
m.syncRemoteStorage();
|
m.syncRemoteStorage();
|
||||||
}
|
}
|
||||||
// Set an initial empty profile so user can be added to groups
|
// Set an initial empty profile so user can be added to groups
|
||||||
|
|||||||
@ -8,10 +8,8 @@ import org.signal.libsignal.metadata.certificate.CertificateValidator;
|
|||||||
import org.signal.libsignal.net.Network;
|
import org.signal.libsignal.net.Network;
|
||||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||||
import org.signal.network.api.AccountApiV2;
|
|
||||||
import org.signal.network.api.AttachmentApi;
|
import org.signal.network.api.AttachmentApi;
|
||||||
import org.signal.network.api.CallingApi;
|
import org.signal.network.api.CallingApi;
|
||||||
import org.signal.network.api.CdnApi;
|
|
||||||
import org.signal.network.api.CdsApi;
|
import org.signal.network.api.CdsApi;
|
||||||
import org.signal.network.api.CertificateApi;
|
import org.signal.network.api.CertificateApi;
|
||||||
import org.signal.network.api.LinkDeviceApi;
|
import org.signal.network.api.LinkDeviceApi;
|
||||||
@ -75,7 +73,6 @@ public class SignalDependencies {
|
|||||||
|
|
||||||
private SignalServiceAccountManager accountManager;
|
private SignalServiceAccountManager accountManager;
|
||||||
private AccountApi accountApi;
|
private AccountApi accountApi;
|
||||||
private AccountApiV2 accountApiV2;
|
|
||||||
private RateLimitChallengeApi rateLimitChallengeApi;
|
private RateLimitChallengeApi rateLimitChallengeApi;
|
||||||
private CdsApi cdsApi;
|
private CdsApi cdsApi;
|
||||||
private UsernameApi usernameApi;
|
private UsernameApi usernameApi;
|
||||||
@ -92,7 +89,6 @@ public class SignalDependencies {
|
|||||||
private ClientZkOperations clientZkOperations;
|
private ClientZkOperations clientZkOperations;
|
||||||
private ProfileService profileService;
|
private ProfileService profileService;
|
||||||
private ProfileApi profileApi;
|
private ProfileApi profileApi;
|
||||||
private CdnApi cdnApi;
|
|
||||||
private CdnService cdnService;
|
private CdnService cdnService;
|
||||||
private PreKeyRepository preKeyRepository;
|
private PreKeyRepository preKeyRepository;
|
||||||
private SignalRestClient signalRestClient;
|
private SignalRestClient signalRestClient;
|
||||||
@ -225,11 +221,6 @@ public class SignalDependencies {
|
|||||||
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
|
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountApiV2 getAccountApiV2() {
|
|
||||||
return getOrCreate(() -> accountApiV2,
|
|
||||||
() -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public RateLimitChallengeApi getRateLimitChallengeApi() {
|
public RateLimitChallengeApi getRateLimitChallengeApi() {
|
||||||
return getOrCreate(() -> rateLimitChallengeApi,
|
return getOrCreate(() -> rateLimitChallengeApi,
|
||||||
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));
|
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));
|
||||||
@ -273,7 +264,7 @@ public class SignalDependencies {
|
|||||||
|
|
||||||
public AttachmentApi getAttachmentApi() {
|
public AttachmentApi getAttachmentApi() {
|
||||||
return getOrCreate(() -> attachmentApi,
|
return getOrCreate(() -> attachmentApi,
|
||||||
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket()));
|
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CallingApi getCallingApi() {
|
public CallingApi getCallingApi() {
|
||||||
@ -355,7 +346,7 @@ public class SignalDependencies {
|
|||||||
|
|
||||||
public CdnService getCdnService() {
|
public CdnService getCdnService() {
|
||||||
return getOrCreate(() -> cdnService,
|
return getOrCreate(() -> cdnService,
|
||||||
() -> cdnService = new CdnService(getCdnApi(), getAttachmentApi(), () -> null));
|
() -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public PreKeyRepository getPreKeyRepository() {
|
public PreKeyRepository getPreKeyRepository() {
|
||||||
@ -380,8 +371,7 @@ public class SignalDependencies {
|
|||||||
ServiceConfig.MAX_ENVELOPE_SIZE,
|
ServiceConfig.MAX_ENVELOPE_SIZE,
|
||||||
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
|
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
|
||||||
() -> true,
|
() -> true,
|
||||||
getPreKeyRepository(),
|
getPreKeyRepository()));
|
||||||
getCdnService()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SecureValueRecovery> getSecureValueRecovery() {
|
public List<SecureValueRecovery> getSecureValueRecovery() {
|
||||||
@ -400,10 +390,6 @@ public class SignalDependencies {
|
|||||||
getClientZkProfileOperations()));
|
getClientZkProfileOperations()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CdnApi getCdnApi() {
|
|
||||||
return getOrCreate(() -> cdnApi, () -> cdnApi = new CdnApi(getSignalRestClient()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProfileService getProfileService() {
|
public ProfileService getProfileService() {
|
||||||
return getOrCreate(() -> profileService,
|
return getOrCreate(() -> profileService,
|
||||||
() -> profileService = new ProfileService(getClientZkProfileOperations(),
|
() -> profileService = new ProfileService(getClientZkProfileOperations(),
|
||||||
|
|||||||
@ -8,11 +8,9 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
|
|||||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.nio.file.AtomicMoveNotSupportedException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public class AttachmentStore {
|
public class AttachmentStore {
|
||||||
@ -53,24 +51,9 @@ public class AttachmentStore {
|
|||||||
|
|
||||||
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
|
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
|
||||||
createAttachmentsDir();
|
createAttachmentsDir();
|
||||||
final var temporaryFile = Files.createTempFile(attachmentsPath.toPath(),
|
try (OutputStream output = new FileOutputStream(attachmentFile)) {
|
||||||
attachmentFile.getName() + ".",
|
|
||||||
".tmp");
|
|
||||||
try {
|
|
||||||
try (OutputStream output = Files.newOutputStream(temporaryFile)) {
|
|
||||||
storer.store(output);
|
storer.store(output);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
Files.move(temporaryFile,
|
|
||||||
attachmentFile.toPath(),
|
|
||||||
StandardCopyOption.ATOMIC_MOVE,
|
|
||||||
StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
} catch (AtomicMoveNotSupportedException e) {
|
|
||||||
Files.move(temporaryFile, attachmentFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Files.deleteIfExists(temporaryFile);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private File getAttachmentPreviewFile(
|
private File getAttachmentPreviewFile(
|
||||||
|
|||||||
@ -71,7 +71,6 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
|||||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||||
import org.signal.network.api.RegistrationApiV2;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
|
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
|
||||||
@ -234,28 +233,6 @@ public class SignalAccount implements Closeable {
|
|||||||
IdentityKeyPair pniIdentityKey,
|
IdentityKeyPair pniIdentityKey,
|
||||||
ProfileKey profileKey,
|
ProfileKey profileKey,
|
||||||
final Settings settings
|
final Settings settings
|
||||||
) throws IOException {
|
|
||||||
return create(dataPath,
|
|
||||||
accountPath,
|
|
||||||
number,
|
|
||||||
null,
|
|
||||||
serviceEnvironment,
|
|
||||||
aciIdentityKey,
|
|
||||||
pniIdentityKey,
|
|
||||||
profileKey,
|
|
||||||
settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SignalAccount create(
|
|
||||||
File dataPath,
|
|
||||||
String accountPath,
|
|
||||||
String number,
|
|
||||||
ACI aci,
|
|
||||||
ServiceEnvironment serviceEnvironment,
|
|
||||||
IdentityKeyPair aciIdentityKey,
|
|
||||||
IdentityKeyPair pniIdentityKey,
|
|
||||||
ProfileKey profileKey,
|
|
||||||
final Settings settings
|
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
IOUtils.createPrivateDirectories(dataPath);
|
IOUtils.createPrivateDirectories(dataPath);
|
||||||
var fileName = getFileName(dataPath, accountPath);
|
var fileName = getFileName(dataPath, accountPath);
|
||||||
@ -274,7 +251,6 @@ public class SignalAccount implements Closeable {
|
|||||||
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||||
|
|
||||||
signalAccount.dataPath = dataPath;
|
signalAccount.dataPath = dataPath;
|
||||||
signalAccount.aciAccountData.setServiceId(aci);
|
|
||||||
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
|
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
|
||||||
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
|
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
|
||||||
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
|
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
|
||||||
@ -374,9 +350,7 @@ public class SignalAccount implements Closeable {
|
|||||||
this.registered = true;
|
this.registered = true;
|
||||||
this.deviceId = deviceId;
|
this.deviceId = deviceId;
|
||||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
||||||
if (pniPreKeys != null) {
|
|
||||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
||||||
}
|
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,53 +392,6 @@ public class SignalAccount implements Closeable {
|
|||||||
clearSessionId();
|
clearSessionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void finishRecoveryRegistration(
|
|
||||||
final ACI aci,
|
|
||||||
final PNI pni,
|
|
||||||
final String number,
|
|
||||||
final AccountEntropyPool accountEntropyPool,
|
|
||||||
final byte[] authCredentialSalt,
|
|
||||||
final PreKeyCollection aciPreKeys,
|
|
||||||
final PreKeyCollection pniPreKeys
|
|
||||||
) {
|
|
||||||
this.pinMasterKey = null;
|
|
||||||
this.accountEntropyPool = accountEntropyPool;
|
|
||||||
this.authCredentialSalt = authCredentialSalt;
|
|
||||||
this.number = number;
|
|
||||||
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
|
|
||||||
this.setStorageManifest(null);
|
|
||||||
this.storageKey = null;
|
|
||||||
this.encryptedDeviceName = null;
|
|
||||||
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
|
||||||
this.isMultiDevice = false;
|
|
||||||
this.registered = true;
|
|
||||||
this.aciAccountData.setServiceId(aci);
|
|
||||||
this.pniAccountData.setServiceId(pni);
|
|
||||||
if (pni == null) {
|
|
||||||
this.pniAccountData.setIdentityKeyPair(null);
|
|
||||||
}
|
|
||||||
init();
|
|
||||||
this.registrationLockPin = null;
|
|
||||||
setLastReceiveTimestamp(0L);
|
|
||||||
setLastAppliedPniChangeServerTimestamp(0L);
|
|
||||||
save();
|
|
||||||
|
|
||||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
|
||||||
if (pni != null && pniPreKeys != null) {
|
|
||||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
|
||||||
}
|
|
||||||
aciAccountData.getSessionStore().archiveAllSessions();
|
|
||||||
pniAccountData.getSessionStore().archiveAllSessions();
|
|
||||||
getSenderKeyStore().deleteAll();
|
|
||||||
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
|
|
||||||
trustSelfIdentity(ServiceIdType.ACI);
|
|
||||||
if (pni != null) {
|
|
||||||
trustSelfIdentity(ServiceIdType.PNI);
|
|
||||||
}
|
|
||||||
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
|
|
||||||
clearSessionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void initDatabase() {
|
public void initDatabase() {
|
||||||
getAccountDatabase();
|
getAccountDatabase();
|
||||||
}
|
}
|
||||||
@ -474,7 +401,7 @@ public class SignalAccount implements Closeable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void migrateLegacyConfigs() {
|
private void migrateLegacyConfigs() {
|
||||||
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) {
|
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
|
||||||
logger.trace("Migrating legacy parts of account file");
|
logger.trace("Migrating legacy parts of account file");
|
||||||
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||||
}
|
}
|
||||||
@ -1496,56 +1423,7 @@ public class SignalAccount implements Closeable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public AccountAttributes.Capabilities getAccountCapabilities() {
|
public AccountAttributes.Capabilities getAccountCapabilities() {
|
||||||
return getCapabilities(isPrimaryDevice(), number != null);
|
return getCapabilities(isPrimaryDevice());
|
||||||
}
|
|
||||||
|
|
||||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
|
|
||||||
return getAccountAttributesV2(false, getRegistrationLock());
|
|
||||||
}
|
|
||||||
|
|
||||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
|
||||||
final boolean includePniRegistrationId,
|
|
||||||
final String registrationLock
|
|
||||||
) {
|
|
||||||
return getAccountAttributesV2(includePniRegistrationId,
|
|
||||||
registrationLock,
|
|
||||||
getRecoveryPassword(),
|
|
||||||
number == null ? null : isDiscoverableByPhoneNumber());
|
|
||||||
}
|
|
||||||
|
|
||||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
|
|
||||||
final String registrationLock,
|
|
||||||
final String recoveryPassword
|
|
||||||
) {
|
|
||||||
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
|
||||||
final boolean includePniRegistrationId,
|
|
||||||
final String registrationLock,
|
|
||||||
final String recoveryPassword,
|
|
||||||
final Boolean discoverableByPhoneNumber
|
|
||||||
) {
|
|
||||||
final var attributes = getAccountAttributes(null);
|
|
||||||
final var capabilities = attributes.getCapabilities();
|
|
||||||
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
|
|
||||||
attributes.getRegistrationId(),
|
|
||||||
attributes.getVoice(),
|
|
||||||
attributes.getVideo(),
|
|
||||||
attributes.getFetchesMessages(),
|
|
||||||
registrationLock,
|
|
||||||
attributes.getUnidentifiedAccessKey(),
|
|
||||||
attributes.getUnrestrictedUnidentifiedAccess(),
|
|
||||||
discoverableByPhoneNumber,
|
|
||||||
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
|
|
||||||
capabilities.getVersionedExpirationTimer(),
|
|
||||||
capabilities.getAttachmentBackfill(),
|
|
||||||
capabilities.getSpqr(),
|
|
||||||
capabilities.getUsernameChangeSyncMessage(),
|
|
||||||
capabilities.getOptionalPhoneNumber()),
|
|
||||||
attributes.getName(),
|
|
||||||
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
|
|
||||||
recoveryPassword);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceId getAccountId(ServiceIdType serviceIdType) {
|
public ServiceId getAccountId(ServiceIdType serviceIdType) {
|
||||||
@ -1758,10 +1636,6 @@ public class SignalAccount implements Closeable {
|
|||||||
return accountEntropyPool;
|
return accountEntropyPool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountEntropyPool getAccountEntropyPool() {
|
|
||||||
return accountEntropyPool;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
|
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
|
||||||
this.accountEntropyPool = accountEntropyPool;
|
this.accountEntropyPool = accountEntropyPool;
|
||||||
if (accountEntropyPool != null) {
|
if (accountEntropyPool != null) {
|
||||||
|
|||||||
@ -63,14 +63,11 @@ public class AccountsStore {
|
|||||||
public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
|
public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
|
||||||
return readAccounts().stream()
|
return readAccounts().stream()
|
||||||
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
||||||
.filter(a -> a.number() != null || a.uuid() != null)
|
.filter(a -> a.number() != null)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String getPathByNumber(String number) throws IOException {
|
public synchronized String getPathByNumber(String number) throws IOException {
|
||||||
if (number == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return readAccounts().stream()
|
return readAccounts().stream()
|
||||||
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
||||||
.filter(a -> number.equals(a.number()))
|
.filter(a -> number.equals(a.number()))
|
||||||
@ -104,7 +101,7 @@ public class AccountsStore {
|
|||||||
if (number != null && number.equals(a.number())) {
|
if (number != null && number.equals(a.number())) {
|
||||||
return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
|
return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
|
||||||
}
|
}
|
||||||
if (aci != null && aci.toString().equals(a.uuid())) {
|
if (aci != null && aci.toString().equals(a.toString())) {
|
||||||
return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
|
return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,7 +188,7 @@ public class AccountsStore {
|
|||||||
private List<AccountsStorage.Account> readAccounts() throws IOException {
|
private List<AccountsStorage.Account> readAccounts() throws IOException {
|
||||||
final var pair = openFileChannel(getAccountsFile());
|
final var pair = openFileChannel(getAccountsFile());
|
||||||
try (final var fileChannel = pair.first(); final var lock = pair.second()) {
|
try (final var fileChannel = pair.first(); final var lock = pair.second()) {
|
||||||
var storage = readAccountsLocked(fileChannel);
|
final var storage = readAccountsLocked(fileChannel);
|
||||||
|
|
||||||
var accountsVersion = storage.version() == null ? 1 : storage.version();
|
var accountsVersion = storage.version() == null ? 1 : storage.version();
|
||||||
if (accountsVersion > CURRENT_STORAGE_VERSION) {
|
if (accountsVersion > CURRENT_STORAGE_VERSION) {
|
||||||
@ -200,7 +197,7 @@ public class AccountsStore {
|
|||||||
throw new IOException("Accounts file was created by a no longer supported older version: "
|
throw new IOException("Accounts file was created by a no longer supported older version: "
|
||||||
+ accountsVersion);
|
+ accountsVersion);
|
||||||
} else if (accountsVersion < CURRENT_STORAGE_VERSION) {
|
} else if (accountsVersion < CURRENT_STORAGE_VERSION) {
|
||||||
storage = upgradeAccountsFile(fileChannel, storage, accountsVersion);
|
return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
|
||||||
}
|
}
|
||||||
return storage.accounts();
|
return storage.accounts();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import org.asamk.signal.manager.storage.Utils;
|
|||||||
import org.asamk.signal.manager.util.KeyUtils;
|
import org.asamk.signal.manager.util.KeyUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord;
|
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@ -351,7 +351,7 @@ public class StickerStore {
|
|||||||
|
|
||||||
public void upsertFromStorageSync(
|
public void upsertFromStorageSync(
|
||||||
final Connection connection,
|
final Connection connection,
|
||||||
final SignalInstalledStickerPackRecord record
|
final SignalStickerPackRecord record
|
||||||
) throws SQLException {
|
) throws SQLException {
|
||||||
final var remote = record.getProto();
|
final var remote = record.getProto();
|
||||||
final var packId = StickerPackId.deserialize(remote.packId.toByteArray());
|
final var packId = StickerPackId.deserialize(remote.packId.toByteArray());
|
||||||
|
|||||||
@ -3,14 +3,14 @@ package org.asamk.signal.manager.syncStorage;
|
|||||||
import org.asamk.signal.manager.api.StickerPackId;
|
import org.asamk.signal.manager.api.StickerPackId;
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
import org.asamk.signal.manager.storage.SignalAccount;
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
import org.asamk.signal.manager.util.KeyUtils;
|
||||||
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord;
|
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalInstalledStickerPackRecord> {
|
public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<SignalStickerPackRecord> {
|
||||||
|
|
||||||
private static final int PACK_ID_LENGTH = 16;
|
private static final int PACK_ID_LENGTH = 16;
|
||||||
private static final int PACK_KEY_LENGTH = 32;
|
private static final int PACK_KEY_LENGTH = 32;
|
||||||
@ -24,19 +24,19 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int compare(final SignalInstalledStickerPackRecord lhs, final SignalInstalledStickerPackRecord rhs) {
|
public int compare(final SignalStickerPackRecord lhs, final SignalStickerPackRecord rhs) {
|
||||||
return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1;
|
return lhs.getProto().packId.equals(rhs.getProto().packId) ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean isInvalid(final SignalInstalledStickerPackRecord remote) {
|
protected boolean isInvalid(final SignalStickerPackRecord remote) {
|
||||||
return remote.getProto().packId.size() != PACK_ID_LENGTH || (
|
return remote.getProto().packId.size() != PACK_ID_LENGTH || (
|
||||||
remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH
|
remote.getProto().deletedAtTimestamp == 0 && remote.getProto().packKey.size() != PACK_KEY_LENGTH
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Optional<SignalInstalledStickerPackRecord> getMatching(final SignalInstalledStickerPackRecord remote) throws SQLException {
|
protected Optional<SignalStickerPackRecord> getMatching(final SignalStickerPackRecord remote) throws SQLException {
|
||||||
final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray());
|
final var packId = StickerPackId.deserialize(remote.getProto().packId.toByteArray());
|
||||||
final var local = account.getStickerStore().getStickerPack(connection, packId);
|
final var local = account.getStickerStore().getStickerPack(connection, packId);
|
||||||
|
|
||||||
@ -52,13 +52,13 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
|
|||||||
account.getStickerStore().updateStorageId(connection, packId, storageId);
|
account.getStickerStore().updateStorageId(connection, packId, storageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.of(new SignalInstalledStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
|
return Optional.of(new SignalStickerPackRecord(storageId, StorageSyncModels.localToRemoteRecord(local)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected SignalInstalledStickerPackRecord merge(
|
protected SignalStickerPackRecord merge(
|
||||||
final SignalInstalledStickerPackRecord remoteRecord,
|
final SignalStickerPackRecord remoteRecord,
|
||||||
final SignalInstalledStickerPackRecord localRecord
|
final SignalStickerPackRecord localRecord
|
||||||
) {
|
) {
|
||||||
final var remote = remoteRecord.getProto();
|
final var remote = remoteRecord.getProto();
|
||||||
final var local = localRecord.getProto();
|
final var local = localRecord.getProto();
|
||||||
@ -75,12 +75,12 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void insertLocal(final SignalInstalledStickerPackRecord record) throws SQLException {
|
protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
|
||||||
account.getStickerStore().upsertFromStorageSync(connection, record);
|
account.getStickerStore().upsertFromStorageSync(connection, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void updateLocal(final StorageRecordUpdate<SignalInstalledStickerPackRecord> update) throws SQLException {
|
protected void updateLocal(final StorageRecordUpdate<SignalStickerPackRecord> update) throws SQLException {
|
||||||
account.getStickerStore().upsertFromStorageSync(connection, update.newRecord());
|
account.getStickerStore().upsertFromStorageSync(connection, update.newRecord());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,14 +17,14 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
|
|||||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||||
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
|
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
|
||||||
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
|
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
|
||||||
import org.whispersystems.signalservice.api.storage.SignalInstalledStickerPackRecord;
|
import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
|
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
|
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord.UsernameLink;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
|
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
|
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
|
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
|
import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
|
||||||
import org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord;
|
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@ -168,8 +168,8 @@ public final class StorageSyncModels {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static InstalledStickerPackRecord localToRemoteRecord(StickerPack stickerPack) {
|
public static StickerPackRecord localToRemoteRecord(StickerPack stickerPack) {
|
||||||
final var builder = SignalInstalledStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord());
|
final var builder = SignalStickerPackRecord.Companion.newBuilder(stickerPack.storageRecord());
|
||||||
builder.packId(ByteString.of(stickerPack.packId().serialize()));
|
builder.packId(ByteString.of(stickerPack.packId().serialize()));
|
||||||
|
|
||||||
if (stickerPack.deletedTimestamp() > 0) {
|
if (stickerPack.deletedTimestamp() > 0) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package org.asamk.signal.manager.util;
|
package org.asamk.signal.manager.util;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||||
@ -28,21 +27,16 @@ public class AttachmentUtils {
|
|||||||
StreamDetails streamDetails,
|
StreamDetails streamDetails,
|
||||||
Optional<String> name,
|
Optional<String> name,
|
||||||
boolean voiceNote,
|
boolean voiceNote,
|
||||||
AttachmentDimensions dimensions,
|
|
||||||
String blurHash,
|
|
||||||
ResumableUploadSpec resumableUploadSpec
|
ResumableUploadSpec resumableUploadSpec
|
||||||
) throws ResumeLocationInvalidException, IOException {
|
) throws ResumeLocationInvalidException, IOException {
|
||||||
final var uploadTimestamp = System.currentTimeMillis();
|
final var uploadTimestamp = System.currentTimeMillis();
|
||||||
final var probedStream = dimensions != null ? new ProbedStream(streamDetails.getStream(),
|
final var probedStream = probeImageDimensions(streamDetails);
|
||||||
dimensions.width(),
|
|
||||||
dimensions.height()) : probeImageDimensions(streamDetails);
|
|
||||||
return SignalServiceAttachmentStream.newStreamBuilder()
|
return SignalServiceAttachmentStream.newStreamBuilder()
|
||||||
.withStream(probedStream.inputStream())
|
.withStream(probedStream.inputStream())
|
||||||
.withContentType(streamDetails.getContentType())
|
.withContentType(streamDetails.getContentType())
|
||||||
.withLength(streamDetails.getLength())
|
.withLength(streamDetails.getLength())
|
||||||
.withFileName(name.orElse(null))
|
.withFileName(name.orElse(null))
|
||||||
.withVoiceNote(voiceNote)
|
.withVoiceNote(voiceNote)
|
||||||
.withBlurHash(blurHash)
|
|
||||||
.withWidth(probedStream.width())
|
.withWidth(probedStream.width())
|
||||||
.withHeight(probedStream.height())
|
.withHeight(probedStream.height())
|
||||||
.withUploadTimestamp(uploadTimestamp)
|
.withUploadTimestamp(uploadTimestamp)
|
||||||
@ -56,7 +50,7 @@ public class AttachmentUtils {
|
|||||||
Optional<String> name,
|
Optional<String> name,
|
||||||
ResumableUploadSpec resumableUploadSpec
|
ResumableUploadSpec resumableUploadSpec
|
||||||
) throws ResumeLocationInvalidException, IOException {
|
) throws ResumeLocationInvalidException, IOException {
|
||||||
return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec);
|
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -42,19 +42,6 @@ public class PhoneNumberFormatter {
|
|||||||
throw new InvalidNumberException("No valid characters found.");
|
throw new InvalidNumberException("No valid characters found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localNumber == null) {
|
|
||||||
if (!number.startsWith("+")) {
|
|
||||||
throw new InvalidNumberException(
|
|
||||||
"Use an international number including the country code for a numberless account.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final var util = PhoneNumberUtil.getInstance();
|
|
||||||
return util.format(util.parse(number, null), PhoneNumberFormat.E164);
|
|
||||||
} catch (NumberParseException e) {
|
|
||||||
throw new InvalidNumberException("Invalid international phone number.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||||
PhoneNumber localNumberObject = util.parse(localNumber, null);
|
PhoneNumber localNumberObject = util.parse(localNumber, null);
|
||||||
|
|||||||
@ -65,7 +65,7 @@ public class ProfileUtils {
|
|||||||
final SignalServiceProfile encryptedProfile,
|
final SignalServiceProfile encryptedProfile,
|
||||||
final ProfileCipher profileCipher
|
final ProfileCipher profileCipher
|
||||||
) {
|
) {
|
||||||
if (encryptedProfile.getUnrestrictedUnidentifiedAccess()) {
|
if (encryptedProfile.isUnrestrictedUnidentifiedAccess()) {
|
||||||
return Profile.UnidentifiedAccessMode.UNRESTRICTED;
|
return Profile.UnidentifiedAccessMode.UNRESTRICTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,14 +81,12 @@ public class ProfileUtils {
|
|||||||
|
|
||||||
public static HashSet<Profile.Capability> getCapabilities(final SignalServiceProfile encryptedProfile) {
|
public static HashSet<Profile.Capability> getCapabilities(final SignalServiceProfile encryptedProfile) {
|
||||||
final var capabilities = new HashSet<Profile.Capability>();
|
final var capabilities = new HashSet<Profile.Capability>();
|
||||||
if (encryptedProfile.getCapabilities() != null) {
|
if (encryptedProfile.getCapabilities().isStorage()) {
|
||||||
if (encryptedProfile.getCapabilities().getStorage()) {
|
|
||||||
capabilities.add(Profile.Capability.storage);
|
capabilities.add(Profile.Capability.storage);
|
||||||
}
|
}
|
||||||
if (encryptedProfile.getCapabilities().getStorageServiceEncryptionV2()) {
|
if (encryptedProfile.getCapabilities().isStorageServiceEncryptionV2()) {
|
||||||
capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability);
|
capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return capabilities;
|
return capabilities;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package org.asamk.signal.manager.util;
|
package org.asamk.signal.manager.util;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.BadRequestException;
|
|
||||||
import org.asamk.signal.manager.api.Pair;
|
import org.asamk.signal.manager.api.Pair;
|
||||||
import org.signal.core.models.ServiceId;
|
import org.signal.core.models.ServiceId;
|
||||||
import org.signal.libsignal.net.BadRequestError;
|
import org.signal.libsignal.net.BadRequestError;
|
||||||
@ -195,8 +194,8 @@ public class Utils {
|
|||||||
}
|
}
|
||||||
} else if (result instanceof RequestResult.RetryableNetworkError e) {
|
} else if (result instanceof RequestResult.RetryableNetworkError e) {
|
||||||
throw e.getNetworkError();
|
throw e.getNetworkError();
|
||||||
} else if (result instanceof RequestResult.NonSuccess<?> e) {
|
} else if (result instanceof RequestResult.NonSuccess) {
|
||||||
throw new BadRequestException(e.getError());
|
throw new AssertionError();
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("Unexpected value: " + result);
|
throw new IllegalStateException("Unexpected value: " + result);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,289 +0,0 @@
|
|||||||
package org.asamk.signal.manager.helper;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
|
||||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
|
||||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.io.StringWriter;
|
|
||||||
import java.net.SocketException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class AttachmentHelperTest {
|
|
||||||
|
|
||||||
private static final String PAYLOAD = Base64.getEncoder()
|
|
||||||
.encodeToString("private attachment marker ".repeat(200).getBytes(StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void preparationFailureIsAttachmentInvalidAndNothingIsUploaded() {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failSpecOnCall = 1;
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).uploadAttachments(List.of(inline("one"), inline("two"))));
|
|
||||||
|
|
||||||
assertEquals("inline attachment #1: spec failed", error.getMessage());
|
|
||||||
assertRedacted(error);
|
|
||||||
assertEquals(0, uploader.uploads.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFailureOnSecondInlineAttachmentStopsBeforeTheThird() {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failUploadOnCall = 2;
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).uploadAttachments(List.of(inline("one"), inline("two"), inline("three"))));
|
|
||||||
|
|
||||||
assertEquals("inline attachment #2: upload failed", error.getMessage());
|
|
||||||
assertSame(uploader.uploadFailure, error.getCause());
|
|
||||||
assertRedacted(error);
|
|
||||||
assertEquals(List.of("one", "two"), uploader.uploads);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void uploadFailureOnSecondFileAttachmentNamesThePath() throws IOException {
|
|
||||||
final var files = List.of(file("a.txt"), file("b.txt"), file("c.txt"));
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failUploadOnCall = 2;
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).uploadAttachments(files));
|
|
||||||
|
|
||||||
assertEquals(files.get(1) + ": upload failed", error.getMessage());
|
|
||||||
assertInstanceOf(SocketException.class, error.getCause());
|
|
||||||
assertEquals(List.of("a.txt", "b.txt"), uploader.uploads);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void successfulUploadsKeepInputOrder() throws Exception {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
final var pointers = helper(uploader).uploadAttachments(List.of(inline("one"),
|
|
||||||
file("two.txt"),
|
|
||||||
inline("three")));
|
|
||||||
|
|
||||||
assertEquals(List.of("one", "two.txt", "three"),
|
|
||||||
pointers.stream()
|
|
||||||
.map(SignalServiceAttachment::asPointer)
|
|
||||||
.map(p -> p.getFileName().orElseThrow())
|
|
||||||
.toList());
|
|
||||||
assertEquals(3, uploader.specs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void malformedInlineAttachmentDoesNotExposePayload() {
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(new FakeUploader()).uploadAttachments(List.of("data:application/octet-stream;base64,"
|
|
||||||
+ PAYLOAD
|
|
||||||
+ "!")));
|
|
||||||
|
|
||||||
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
|
|
||||||
assertRedacted(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void upperCaseInlineAttachmentIsNotTreatedAsAFilePath() throws Exception {
|
|
||||||
// Longer than PATH_MAX, so canonicalizing it as a file path would fail
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
final var pointers = helper(uploader).uploadAttachments(List.of(
|
|
||||||
"DATA:application/octet-stream;filename=upper;base64," + PAYLOAD));
|
|
||||||
|
|
||||||
assertEquals(1, pointers.size());
|
|
||||||
assertEquals(List.of("upper"), uploader.uploads);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"data:text/plain;base64", "data://", "data:;base64//", "DATA://"})
|
|
||||||
void inlineAttachmentWithoutCommaIsNotExposed(final String prefix) {
|
|
||||||
// Short enough to reach file opening if inline input is incorrectly treated as a path.
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(new FakeUploader()).uploadAttachments(List.of(prefix + PAYLOAD.substring(0, 80))));
|
|
||||||
|
|
||||||
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
|
|
||||||
assertRedacted(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void longInlineAttachmentWithoutCommaIsRejected() {
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(new FakeUploader()).uploadAttachments(List.of("data:" + PAYLOAD.repeat(160))));
|
|
||||||
|
|
||||||
assertEquals("inline attachment #1: Invalid data URI", error.getMessage());
|
|
||||||
assertRedacted(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void defaultUploadUsesTheSameErrorContract() {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failUploadOnCall = 1;
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).uploadAttachment(inline("image")));
|
|
||||||
|
|
||||||
assertEquals("inline attachment: upload failed", error.getMessage());
|
|
||||||
assertSame(uploader.uploadFailure, error.getCause());
|
|
||||||
assertRedacted(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void stickerPreparationFailureIsLabelled() {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failSpecOnCall = 1;
|
|
||||||
final var stream = new ByteArrayInputStream(new byte[4]);
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).createStickerAttachmentStream(new StreamDetails(stream, "image/webp", 4)));
|
|
||||||
|
|
||||||
assertEquals("sticker: spec failed", error.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(booleans = {true, false})
|
|
||||||
void longTextFailureNeverEchoesTheText(final boolean preparation) {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failSpecOnCall = preparation ? 1 : 0;
|
|
||||||
uploader.failUploadOnCall = preparation ? 0 : 1;
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper(uploader).uploadLongTextAttachment(PAYLOAD.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
|
|
||||||
assertEquals("long text attachment: " + (preparation ? "spec" : "upload") + " failed", error.getMessage());
|
|
||||||
assertRedacted(error);
|
|
||||||
assertInstanceOf(SocketException.class, error.getCause());
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {"quote thumbnail #1", "link preview image #1"})
|
|
||||||
void imageFailuresUseTheirLabel(final String label) {
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
uploader.failSpecOnCall = 1;
|
|
||||||
final var helper = helper(uploader);
|
|
||||||
final var preparationError = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper.uploadAttachment(inline("image"), label));
|
|
||||||
assertEquals(label + ": spec failed", preparationError.getMessage());
|
|
||||||
assertRedacted(preparationError);
|
|
||||||
assertEquals(0, uploader.uploads.size());
|
|
||||||
|
|
||||||
uploader.failUploadOnCall = 1;
|
|
||||||
final var uploadError = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> helper.uploadAttachment(inline("image"), label));
|
|
||||||
assertEquals(label + ": upload failed", uploadError.getMessage());
|
|
||||||
assertRedacted(uploadError);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void filesInTheDataDirectoryAreStillRejected() throws IOException {
|
|
||||||
final var dataDir = Files.createDirectory(tempDir.resolve("data")).toFile();
|
|
||||||
final var secret = new File(dataDir, "account.db");
|
|
||||||
Files.writeString(secret.toPath(), "secret");
|
|
||||||
final var uploader = new FakeUploader();
|
|
||||||
final var error = assertThrows(AttachmentInvalidException.class,
|
|
||||||
() -> new AttachmentHelper(uploader, () -> dataDir).uploadAttachments(List.of(secret.getPath())));
|
|
||||||
|
|
||||||
assertEquals(secret.getPath() + ": Attaching files from the signal-cli data directory is not allowed",
|
|
||||||
error.getMessage());
|
|
||||||
assertEquals(0, uploader.specs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void invalidWithoutCauseMessageFallsBackToClassName() {
|
|
||||||
final var cause = new SocketException();
|
|
||||||
final var error = AttachmentHelper.invalid("inline attachment #1", cause);
|
|
||||||
|
|
||||||
assertEquals("inline attachment #1: java.net.SocketException", error.getMessage());
|
|
||||||
assertSame(cause, error.getCause());
|
|
||||||
}
|
|
||||||
|
|
||||||
private AttachmentHelper helper(final FakeUploader uploader) {
|
|
||||||
return new AttachmentHelper(uploader, () -> tempDir.resolve("data").toFile());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String file(final String name) throws IOException {
|
|
||||||
final var path = tempDir.resolve(name);
|
|
||||||
Files.writeString(path, PAYLOAD);
|
|
||||||
return path.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String inline(final String filename) {
|
|
||||||
return "data:application/octet-stream;filename=" + filename + ";base64," + PAYLOAD;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void assertRedacted(final AttachmentInvalidException error) {
|
|
||||||
final var trace = new StringWriter();
|
|
||||||
error.printStackTrace(new PrintWriter(trace));
|
|
||||||
assertFalse(trace.toString().contains(PAYLOAD.substring(0, 32)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeUploader implements AttachmentHelper.Uploader {
|
|
||||||
|
|
||||||
final IOException uploadFailure = new SocketException("upload failed");
|
|
||||||
int failSpecOnCall;
|
|
||||||
int failUploadOnCall;
|
|
||||||
int specs;
|
|
||||||
final List<String> uploads = new ArrayList<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResumableUploadSpec getResumableUploadSpec(final long ciphertextLength) throws IOException {
|
|
||||||
if (++specs == failSpecOnCall) {
|
|
||||||
throw new SocketException("spec failed");
|
|
||||||
}
|
|
||||||
return new ResumableUploadSpec(new byte[64],
|
|
||||||
new byte[16],
|
|
||||||
"cdn-key",
|
|
||||||
3,
|
|
||||||
"https://cdn.invalid/upload",
|
|
||||||
Long.MAX_VALUE,
|
|
||||||
Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SignalServiceAttachmentPointer upload(final SignalServiceAttachmentStream stream) throws IOException {
|
|
||||||
final var fileName = stream.getFileName().orElse("");
|
|
||||||
uploads.add(fileName);
|
|
||||||
if (uploads.size() == failUploadOnCall) {
|
|
||||||
throw uploadFailure;
|
|
||||||
}
|
|
||||||
return new SignalServiceAttachmentPointer(3,
|
|
||||||
SignalServiceAttachmentRemoteId.from("cdn-key", 3),
|
|
||||||
stream.getContentType(),
|
|
||||||
new byte[64],
|
|
||||||
Optional.of((int) stream.getLength()),
|
|
||||||
Optional.empty(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
0,
|
|
||||||
Optional.of(fileName),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
0,
|
|
||||||
UUID.randomUUID());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +1,16 @@
|
|||||||
package org.asamk.signal.manager.helper;
|
package org.asamk.signal.manager.helper;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.CallInfo;
|
import org.asamk.signal.manager.api.CallInfo;
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
|
||||||
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
|
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
|
||||||
import org.asamk.signal.manager.util.Utils;
|
import org.asamk.signal.manager.util.Utils;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
import java.lang.invoke.MethodHandle;
|
import java.lang.invoke.MethodHandle;
|
||||||
import java.lang.invoke.MethodHandles;
|
import java.lang.invoke.MethodHandles;
|
||||||
import java.lang.invoke.MethodType;
|
import java.lang.invoke.MethodType;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.nio.channels.FileChannel;
|
|
||||||
import java.nio.channels.FileLock;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
@ -29,8 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
*/
|
*/
|
||||||
class CallManagerTest {
|
class CallManagerTest {
|
||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
||||||
|
|
||||||
// --- Reflection helpers for private static methods ---
|
// --- Reflection helpers for private static methods ---
|
||||||
|
|
||||||
private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES;
|
private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES;
|
||||||
@ -185,38 +177,6 @@ class CallManagerTest {
|
|||||||
assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row");
|
assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildConfig_usesProvidedLinkedDeviceId() throws Exception {
|
|
||||||
var state = makeCallState(123L, CallInfo.State.RINGING_OUTGOING);
|
|
||||||
|
|
||||||
var config = OBJECT_MAPPER.readTree(CallManager.buildConfig(state, 7));
|
|
||||||
|
|
||||||
assertEquals(7, config.get("local_device_id").intValue());
|
|
||||||
assertEquals(123L, config.get("call_id").longValue());
|
|
||||||
assertTrue(config.get("is_outgoing").booleanValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@CsvSource({"1, true", "1, false", "2, true", "2, false", "127, true", "127, false"})
|
|
||||||
void buildConfig_readsAccountDeviceId(int deviceId, boolean outgoing) throws Exception {
|
|
||||||
var constructor = SignalAccount.class.getDeclaredConstructor(FileChannel.class, FileLock.class);
|
|
||||||
constructor.setAccessible(true);
|
|
||||||
var account = constructor.newInstance(null, null);
|
|
||||||
var deviceField = SignalAccount.class.getDeclaredField("deviceId");
|
|
||||||
deviceField.setAccessible(true);
|
|
||||||
deviceField.setInt(account, deviceId);
|
|
||||||
var builder = CallManager.class.getDeclaredMethod("buildConfig", CallManager.CallState.class);
|
|
||||||
builder.setAccessible(true);
|
|
||||||
|
|
||||||
try (var manager = new CallManager(new Context(account, null, null, null, null, null))) {
|
|
||||||
var state = new CallManager.CallState(-1L, CallInfo.State.IDLE, null, null, outgoing);
|
|
||||||
var config = new ObjectMapper().readTree((String) builder.invoke(manager, state));
|
|
||||||
assertEquals(deviceId, config.path("local_device_id").asInt());
|
|
||||||
assertEquals(outgoing, config.path("is_outgoing").asBoolean());
|
|
||||||
assertEquals("18446744073709551615", config.path("call_id").asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// handleStateChange state machine tests
|
// handleStateChange state machine tests
|
||||||
//
|
//
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
package org.asamk.signal.manager.helper;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.Settings;
|
|
||||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
|
||||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
|
|
||||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
|
|
||||||
class NumberlessGroupTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path directory;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception {
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
|
||||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) {
|
|
||||||
account.setProvisioningData(null,
|
|
||||||
aci,
|
|
||||||
null,
|
|
||||||
"test-password",
|
|
||||||
new byte[]{1},
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
null,
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
null,
|
|
||||||
new byte[32],
|
|
||||||
null);
|
|
||||||
final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString())
|
|
||||||
.joinedAtRevision(1)
|
|
||||||
.build();
|
|
||||||
final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString())
|
|
||||||
.joinedAtRevision(4)
|
|
||||||
.build();
|
|
||||||
final var group = new DecryptedGroup.Builder().revision(9)
|
|
||||||
.members(List.of(otherMember, selfMember))
|
|
||||||
.build();
|
|
||||||
assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group));
|
|
||||||
assertEquals(9,
|
|
||||||
context.getGroupV2Helper()
|
|
||||||
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
package org.asamk.signal.manager.internal;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.Settings;
|
|
||||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
|
||||||
import org.signal.core.models.ServiceId.PNI;
|
|
||||||
import org.signal.network.api.RegistrationApiV2;
|
|
||||||
import org.signal.network.rest.SignalRestClient;
|
|
||||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
|
||||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
import okhttp3.Credentials;
|
|
||||||
import okhttp3.MediaType;
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
import okhttp3.Protocol;
|
|
||||||
import okhttp3.Request;
|
|
||||||
import okhttp3.Response;
|
|
||||||
import okhttp3.ResponseBody;
|
|
||||||
import okio.Buffer;
|
|
||||||
import okio.ByteString;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class NumberlessProvisioningTest {
|
|
||||||
|
|
||||||
private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path directory;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberlessProvisioningRequiresGroupCredentialSalt() {
|
|
||||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
|
|
||||||
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
|
|
||||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
|
|
||||||
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception {
|
|
||||||
final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32]));
|
|
||||||
assertNull(ProvisioningManagerImpl.parsePni(message.build()));
|
|
||||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build()));
|
|
||||||
assertThrows(IllegalArgumentException.class,
|
|
||||||
() -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build()));
|
|
||||||
assertEquals(PNI_ID,
|
|
||||||
ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123")
|
|
||||||
.pni(PNI_ID.toString())
|
|
||||||
.build()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
|
|
||||||
checkLinkRequest(null, null, 200, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
|
|
||||||
checkLinkRequest("+12025550123", PNI_ID, 200, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@CsvSource({
|
|
||||||
"422, Signal rejected the device linking request",
|
|
||||||
"403, [403] Device verification failed",
|
|
||||||
"409, Linked device is missing a required account capability",
|
|
||||||
"411, Account has reached its linked device limit",
|
|
||||||
"429, Device linking rate limited; try again later"
|
|
||||||
})
|
|
||||||
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
|
|
||||||
final int statusCode,
|
|
||||||
final String errorMessage
|
|
||||||
) throws Exception {
|
|
||||||
checkLinkRequest(null, null, statusCode, errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkLinkRequest(
|
|
||||||
final String number,
|
|
||||||
final PNI pni,
|
|
||||||
final int statusCode,
|
|
||||||
final String errorMessage
|
|
||||||
) throws Exception {
|
|
||||||
final var request = new AtomicReference<Request>();
|
|
||||||
final var body = new AtomicReference<JsonNode>();
|
|
||||||
final var mapper = new ObjectMapper();
|
|
||||||
final var client = new OkHttpClient.Builder().addInterceptor(chain -> {
|
|
||||||
request.set(chain.request());
|
|
||||||
final var buffer = new Buffer();
|
|
||||||
chain.request().body().writeTo(buffer);
|
|
||||||
body.set(mapper.readTree(buffer.readUtf8()));
|
|
||||||
return new Response.Builder().request(chain.request())
|
|
||||||
.protocol(Protocol.HTTP_1_1)
|
|
||||||
.code(statusCode)
|
|
||||||
.message("Test response")
|
|
||||||
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
|
|
||||||
MediaType.get("application/json")))
|
|
||||||
.build();
|
|
||||||
}).build();
|
|
||||||
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
|
|
||||||
final var restClient = new SignalRestClient(config.signalServiceConfiguration(),
|
|
||||||
"signal-cli-test",
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
config.signalServiceConfiguration(),
|
|
||||||
() -> false,
|
|
||||||
1000L,
|
|
||||||
new SecureRandom(),
|
|
||||||
client);
|
|
||||||
final var api = new RegistrationApiV2(restClient, false);
|
|
||||||
|
|
||||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
Settings.DEFAULT)) {
|
|
||||||
account.setProvisioningData(number,
|
|
||||||
ACI_ID,
|
|
||||||
pni,
|
|
||||||
"test-password",
|
|
||||||
new byte[]{1},
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
pni == null ? null : KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
null,
|
|
||||||
new byte[32],
|
|
||||||
null);
|
|
||||||
final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
|
||||||
final var pniKeys = pni == null
|
|
||||||
? null
|
|
||||||
: KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
|
||||||
if (statusCode == 200) {
|
|
||||||
assertEquals(2,
|
|
||||||
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
|
|
||||||
} else {
|
|
||||||
final var error = assertThrows(IOException.class,
|
|
||||||
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
|
|
||||||
account,
|
|
||||||
"test-code",
|
|
||||||
aciKeys,
|
|
||||||
pniKeys));
|
|
||||||
assertEquals(errorMessage, error.getMessage());
|
|
||||||
if (statusCode == 403) {
|
|
||||||
assertInstanceOf(AuthorizationFailedException.class, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals("PUT", request.get().method());
|
|
||||||
assertEquals("/v1/devices/link", request.get().url().encodedPath());
|
|
||||||
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
|
|
||||||
assertTrue(body.get()
|
|
||||||
.path("accountAttributes")
|
|
||||||
.path("capabilities")
|
|
||||||
.path("optionalPhoneNumber")
|
|
||||||
.asBoolean());
|
|
||||||
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
|
|
||||||
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
|
|
||||||
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
|
|
||||||
assertEquals(pni != null, body.get().has("pniPqLastResortPreKey"));
|
|
||||||
assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId"));
|
|
||||||
} finally {
|
|
||||||
client.dispatcher().executorService().shutdown();
|
|
||||||
client.connectionPool().evictAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
package org.asamk.signal.manager.internal;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.asamk.signal.manager.Settings;
|
|
||||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
|
||||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
|
||||||
import org.asamk.signal.manager.helper.AccountFileUpdater;
|
|
||||||
import org.asamk.signal.manager.helper.Context;
|
|
||||||
import org.asamk.signal.manager.helper.ReceiveHelper;
|
|
||||||
import org.asamk.signal.manager.storage.SignalAccount;
|
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.Timeout;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
|
||||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.security.Security;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
import java.util.function.BooleanSupplier;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
@Timeout(60)
|
|
||||||
class ReceiveThreadTest {
|
|
||||||
|
|
||||||
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path directory;
|
|
||||||
|
|
||||||
private ManagerImpl manager;
|
|
||||||
private FakeReceiveHelper receiveHelper;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws Exception {
|
|
||||||
// ManagerImpl.close() creates the push service socket, whose trust store needs BouncyCastle
|
|
||||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
|
||||||
Security.addProvider(new BouncyCastleProvider());
|
|
||||||
}
|
|
||||||
|
|
||||||
final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
Settings.DEFAULT);
|
|
||||||
account.setProvisioningData(null,
|
|
||||||
ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"),
|
|
||||||
null,
|
|
||||||
"test-password",
|
|
||||||
new byte[]{1},
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
null,
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
null,
|
|
||||||
new byte[32],
|
|
||||||
null);
|
|
||||||
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
|
|
||||||
manager = new ManagerImpl(account,
|
|
||||||
PathConfig.createDefault(directory.toFile()),
|
|
||||||
new AccountFileUpdater() {
|
|
||||||
@Override
|
|
||||||
public void updateAccountIdentifiers(final String number, final ACI aci) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeAccount() {
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test"),
|
|
||||||
"signal-cli-test");
|
|
||||||
|
|
||||||
// Replace the receive helper, so the receive thread doesn't connect to the server
|
|
||||||
final var contextField = ManagerImpl.class.getDeclaredField("context");
|
|
||||||
contextField.setAccessible(true);
|
|
||||||
final var context = (Context) contextField.get(manager);
|
|
||||||
receiveHelper = new FakeReceiveHelper(context);
|
|
||||||
final var receiveHelperField = Context.class.getDeclaredField("receiveHelper");
|
|
||||||
receiveHelperField.setAccessible(true);
|
|
||||||
receiveHelperField.set(context, receiveHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
receiveHelper.setHoldStoppingLoops(false);
|
|
||||||
manager.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void handlerAddedWhileReceiveThreadIsStoppingDoesNotStartSecondReceiveThread() throws Exception {
|
|
||||||
final var first = newHandler();
|
|
||||||
final var second = newHandler();
|
|
||||||
|
|
||||||
manager.addReceiveHandler(first);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
|
|
||||||
|
|
||||||
receiveHelper.setHoldStoppingLoops(true);
|
|
||||||
final var remover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(first));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.stoppingLoops == 1));
|
|
||||||
|
|
||||||
// Like a client that reconnects while the receive thread for its previous connection is still stopping
|
|
||||||
manager.addReceiveHandler(second);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 1));
|
|
||||||
|
|
||||||
receiveHelper.setHoldStoppingLoops(false);
|
|
||||||
assertTrue(remover.join(TIMEOUT));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.startedLoops == 2 && receiveHelper.runningLoops == 1));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 2 && receiveHelper.maxRunningLoops == 1));
|
|
||||||
|
|
||||||
manager.removeReceiveHandler(second);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void stopRequestedForStoppingReceiveThreadDoesNotStopNextReceiveThread() throws Exception {
|
|
||||||
final var first = newHandler();
|
|
||||||
final var second = newHandler();
|
|
||||||
final var third = newHandler();
|
|
||||||
|
|
||||||
manager.addReceiveHandler(first);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
|
|
||||||
|
|
||||||
receiveHelper.setHoldStoppingLoops(true);
|
|
||||||
final var firstRemover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(first));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.stoppingLoops == 1));
|
|
||||||
manager.addReceiveHandler(second);
|
|
||||||
final var secondRemover = Thread.ofPlatform().start(() -> manager.removeReceiveHandler(second));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.stopRequests == 2));
|
|
||||||
|
|
||||||
receiveHelper.setHoldStoppingLoops(false);
|
|
||||||
assertTrue(firstRemover.join(TIMEOUT));
|
|
||||||
assertTrue(secondRemover.join(TIMEOUT));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0 && receiveHelper.startedThreads == 1));
|
|
||||||
|
|
||||||
manager.addReceiveHandler(third);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.startedLoops == 2 && receiveHelper.runningLoops == 1));
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.startedThreads == 2 && receiveHelper.maxRunningLoops == 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void lastHandlerStillGetsTheMessageInHandWhenItIsRemoved() throws Exception {
|
|
||||||
final var received = new AtomicInteger();
|
|
||||||
final var handler = new Manager.ReceiveMessageHandler() {
|
|
||||||
@Override
|
|
||||||
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
|
|
||||||
received.incrementAndGet();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
manager.addReceiveHandler(handler);
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 1));
|
|
||||||
|
|
||||||
// Like an envelope that is already cached and acknowledged, and still being handled when the last
|
|
||||||
// handler goes: the loop passes it on before it stops
|
|
||||||
receiveHelper.setPassOnOneWhenStopped(true);
|
|
||||||
manager.removeReceiveHandler(handler);
|
|
||||||
|
|
||||||
assertEquals(1, received.get());
|
|
||||||
assertTrue(receiveHelper.await(() -> receiveHelper.runningLoops == 0 && receiveHelper.startedThreads == 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Manager.ReceiveMessageHandler newHandler() {
|
|
||||||
// An anonymous class instead of a lambda, so every handler is a separate instance
|
|
||||||
return new Manager.ReceiveMessageHandler() {
|
|
||||||
@Override
|
|
||||||
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instead of receiving messages from the server, a receive loop waits until it has been asked to stop.
|
|
||||||
* While holdStoppingLoops is set, a loop that has been asked to stop doesn't return yet, like a loop that
|
|
||||||
* is still busy when the last handler is removed.
|
|
||||||
*/
|
|
||||||
private static class FakeReceiveHelper extends ReceiveHelper {
|
|
||||||
|
|
||||||
private final Object lock = new Object();
|
|
||||||
private boolean stopRequested;
|
|
||||||
private boolean holdStoppingLoops;
|
|
||||||
private boolean passOnOneWhenStopped;
|
|
||||||
private int stopRequests;
|
|
||||||
private int startedThreads;
|
|
||||||
private int startedLoops;
|
|
||||||
private int runningLoops;
|
|
||||||
private int maxRunningLoops;
|
|
||||||
private int stoppingLoops;
|
|
||||||
|
|
||||||
FakeReceiveHelper(final Context context) {
|
|
||||||
super(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean requestStopReceiveMessages() {
|
|
||||||
super.requestStopReceiveMessages();
|
|
||||||
synchronized (lock) {
|
|
||||||
stopRequested = true;
|
|
||||||
stopRequests++;
|
|
||||||
lock.notifyAll();
|
|
||||||
}
|
|
||||||
// The fake loop isn't blocked in a read that needs to be interrupted
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void clearStopRequest() {
|
|
||||||
super.clearStopRequest();
|
|
||||||
synchronized (lock) {
|
|
||||||
stopRequested = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void receiveMessagesContinuously(final Manager.ReceiveMessageHandler handler) {
|
|
||||||
synchronized (lock) {
|
|
||||||
startedThreads++;
|
|
||||||
lock.notifyAll();
|
|
||||||
}
|
|
||||||
super.receiveMessagesContinuously(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void receiveMessages(
|
|
||||||
final Optional<Duration> timeout,
|
|
||||||
final Integer maxMessages,
|
|
||||||
final Manager.ReceiveMessageHandler handler
|
|
||||||
) {
|
|
||||||
synchronized (lock) {
|
|
||||||
startedLoops++;
|
|
||||||
runningLoops++;
|
|
||||||
maxRunningLoops = Math.max(maxRunningLoops, runningLoops);
|
|
||||||
lock.notifyAll();
|
|
||||||
if (await(() -> stopRequested)) {
|
|
||||||
if (passOnOneWhenStopped) {
|
|
||||||
handler.handleMessage(null, null);
|
|
||||||
}
|
|
||||||
stoppingLoops++;
|
|
||||||
lock.notifyAll();
|
|
||||||
await(() -> !holdStoppingLoops);
|
|
||||||
stoppingLoops--;
|
|
||||||
}
|
|
||||||
runningLoops--;
|
|
||||||
lock.notifyAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setPassOnOneWhenStopped(final boolean passOn) {
|
|
||||||
synchronized (lock) {
|
|
||||||
passOnOneWhenStopped = passOn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setHoldStoppingLoops(final boolean hold) {
|
|
||||||
synchronized (lock) {
|
|
||||||
holdStoppingLoops = hold;
|
|
||||||
lock.notifyAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits until the condition, which is evaluated while holding the lock, is true.
|
|
||||||
* Returns false if it didn't become true within the timeout.
|
|
||||||
*/
|
|
||||||
boolean await(final BooleanSupplier condition) {
|
|
||||||
synchronized (lock) {
|
|
||||||
final var deadline = System.nanoTime() + TIMEOUT.toNanos();
|
|
||||||
while (!condition.getAsBoolean()) {
|
|
||||||
final var remainingMillis = (deadline - System.nanoTime()) / 1_000_000;
|
|
||||||
if (remainingMillis <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
lock.wait(remainingMillis);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
package org.asamk.signal.manager.storage;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.Settings;
|
|
||||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.signal.core.models.AccountEntropyPool;
|
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
|
||||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class NumberlessAccountTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path directory;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberlessLinkedAccountSurvivesReload() throws Exception {
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
final var identity = KeyUtils.generateIdentityKeyPair();
|
|
||||||
final var salt = new byte[32];
|
|
||||||
salt[0] = 42;
|
|
||||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
Settings.DEFAULT)) {
|
|
||||||
account.setProvisioningData(null,
|
|
||||||
aci,
|
|
||||||
null,
|
|
||||||
"test-password",
|
|
||||||
new byte[]{1},
|
|
||||||
identity,
|
|
||||||
null,
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
null,
|
|
||||||
salt,
|
|
||||||
null);
|
|
||||||
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
|
|
||||||
assertTrue(account.isRegistered());
|
|
||||||
assertFalse(account.isPrimaryDevice());
|
|
||||||
assertEquals(2, account.getDeviceId());
|
|
||||||
assertEquals(aci, account.getAci());
|
|
||||||
assertNull(account.getNumber());
|
|
||||||
assertNull(account.getPni());
|
|
||||||
assertNull(account.getPniIdentityKeyPair());
|
|
||||||
assertNull(account.getSignalServiceDataStore().pniOrNull());
|
|
||||||
assertArrayEquals(identity.serialize(), account.getAciIdentityKeyPair().serialize());
|
|
||||||
assertArrayEquals(salt, account.getAuthCredentialSalt());
|
|
||||||
assertNull(account.getAccountAttributesV2().getPniRegistrationId());
|
|
||||||
assertNull(account.getAccountAttributesV2().getDiscoverableByPhoneNumber());
|
|
||||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
final var entropyPool = AccountEntropyPool.Companion.generate();
|
|
||||||
final var salt = new byte[32];
|
|
||||||
salt[0] = 42;
|
|
||||||
try (final var account = SignalAccount.create(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
null,
|
|
||||||
aci,
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
Settings.DEFAULT)) {
|
|
||||||
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
|
||||||
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
|
||||||
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
|
|
||||||
assertTrue(account.isRegistered());
|
|
||||||
assertTrue(account.isPrimaryDevice());
|
|
||||||
assertEquals(aci, account.getAci());
|
|
||||||
assertNull(account.getNumber());
|
|
||||||
assertNull(account.getPni());
|
|
||||||
assertNull(account.getPniIdentityKeyPair());
|
|
||||||
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
|
|
||||||
assertArrayEquals(salt, account.getAuthCredentialSalt());
|
|
||||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
try (final var account = SignalAccount.create(directory.toFile(),
|
|
||||||
"account",
|
|
||||||
"+12025550123",
|
|
||||||
aci,
|
|
||||||
ServiceEnvironment.STAGING,
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.generateIdentityKeyPair(),
|
|
||||||
KeyUtils.createProfileKey(),
|
|
||||||
Settings.DEFAULT)) {
|
|
||||||
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
|
|
||||||
.getDiscoverableByPhoneNumber());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
package org.asamk.signal.manager.storage.accounts;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.signal.core.models.ServiceId.ACI;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class AccountsStoreTest {
|
|
||||||
|
|
||||||
private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path directory;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception {
|
|
||||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
final var path = store.addAccount(null, aci);
|
|
||||||
store.addAccount("+12025550123", null);
|
|
||||||
|
|
||||||
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null);
|
|
||||||
assertEquals(2, reopened.getAllAccounts().size());
|
|
||||||
assertEquals(path, reopened.getPathByAci(aci));
|
|
||||||
assertNull(reopened.getPathByNumber(null));
|
|
||||||
assertEquals(Set.of("+12025550123"), reopened.getAllNumbers());
|
|
||||||
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
|
|
||||||
.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception {
|
|
||||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
|
||||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
|
||||||
final var oldPath = store.addAccount(null, aci);
|
|
||||||
Files.createFile(directory.resolve(oldPath));
|
|
||||||
final var newPath = store.addAccount(null, null);
|
|
||||||
store.updateAccount(newPath, null, aci);
|
|
||||||
|
|
||||||
assertEquals(newPath, store.getPathByAci(aci));
|
|
||||||
assertEquals(1, store.getAllAccounts().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(booleans = {false, true})
|
|
||||||
void replacingANumberKeepsTheOldAccountAvailableByAci(final boolean update) throws Exception {
|
|
||||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
|
||||||
final var oldPath = store.addAccount("+12025550123", OLD_ACI);
|
|
||||||
Files.createFile(directory.resolve(oldPath));
|
|
||||||
final String newPath;
|
|
||||||
if (update) {
|
|
||||||
newPath = store.addAccount("+12025550124", NEW_ACI);
|
|
||||||
store.updateAccount(newPath, "+12025550123", NEW_ACI);
|
|
||||||
} else {
|
|
||||||
newPath = store.addAccount("+12025550123", NEW_ACI);
|
|
||||||
}
|
|
||||||
|
|
||||||
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
|
||||||
assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened));
|
|
||||||
assertEquals(newPath, reopened.getPathByNumber("+12025550123"));
|
|
||||||
assertEquals(oldPath, reopened.getPathByAci(OLD_ACI));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<String> getAccountPaths(final AccountsStore store) throws IOException {
|
|
||||||
return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package org.asamk.signal.manager.util;
|
package org.asamk.signal.manager.util;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||||
|
|
||||||
@ -44,42 +43,6 @@ class AttachmentUtilsTest {
|
|||||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception {
|
|
||||||
final var bytes = "opaque video bytes".getBytes();
|
|
||||||
final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
|
|
||||||
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
|
|
||||||
final var attachment = AttachmentUtils.createAttachmentStream(details,
|
|
||||||
Optional.of("clip.mp4"),
|
|
||||||
false,
|
|
||||||
new AttachmentDimensions(1080, 1920),
|
|
||||||
blurHash,
|
|
||||||
null);
|
|
||||||
|
|
||||||
assertEquals(1080, attachment.getWidth());
|
|
||||||
assertEquals(1920, attachment.getHeight());
|
|
||||||
assertEquals(Optional.of(blurHash), attachment.getBlurHash());
|
|
||||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void createAttachmentStream_skipsProbingWhenDimensionsSupplied() throws Exception {
|
|
||||||
final var imageBytes = pngBytes(37, 21);
|
|
||||||
final var stream = new ByteArrayInputStream(imageBytes);
|
|
||||||
final var details = new StreamDetails(stream, "image/png", imageBytes.length);
|
|
||||||
final var attachment = AttachmentUtils.createAttachmentStream(details,
|
|
||||||
Optional.of("meme.png"),
|
|
||||||
false,
|
|
||||||
new AttachmentDimensions(100, 200),
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
|
|
||||||
assertEquals(imageBytes.length, stream.available());
|
|
||||||
assertEquals(100, attachment.getWidth());
|
|
||||||
assertEquals(200, attachment.getHeight());
|
|
||||||
assertArrayEquals(imageBytes, attachment.getInputStream().readAllBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] pngBytes(final int width, final int height) throws Exception {
|
private static byte[] pngBytes(final int width, final int height) throws Exception {
|
||||||
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
final var out = new ByteArrayOutputStream();
|
final var out = new ByteArrayOutputStream();
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
package org.asamk.signal.manager.util;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.InvalidNumberException;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class PhoneNumberFormatterTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberlessAccountsCanAddressInternationalPhoneNumbers() throws Exception {
|
|
||||||
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("+1 (202) 555-0123", null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberlessAccountsCannotInferACountryCode() {
|
|
||||||
assertThrows(InvalidNumberException.class, () -> PhoneNumberFormatter.formatNumber("2025550123", null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void numberedAccountsStillInferTheirCountryCode() throws Exception {
|
|
||||||
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("2025550123", "+12025550124"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +1 @@
|
|||||||
0.105.4
|
0.102.1
|
||||||
|
|||||||
@ -15,13 +15,9 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
|
|||||||
|
|
||||||
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
|
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
|
||||||
|
|
||||||
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/<account-id>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
|
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
|
||||||
|
|
||||||
Note: when daemon was started without explicit `-a ACCOUNT`, use the account's
|
Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenumber>`.
|
||||||
object path returned by `org.asamk.SignalControl.listAccounts` or
|
|
||||||
`org.asamk.SignalControl.getAccount`. Numbered accounts use
|
|
||||||
`/org/asamk/Signal/_<phonenumber>`. Accounts without a phone number use
|
|
||||||
`/org/asamk/Signal/<ACI>`, with hyphens replaced by underscores.
|
|
||||||
|
|
||||||
== Description
|
== Description
|
||||||
|
|
||||||
@ -50,16 +46,9 @@ The final parenthetical value (such as "boolean:") is the type indicator used by
|
|||||||
Exceptions are the names of the Java Exceptions returned in the body field.
|
Exceptions are the names of the Java Exceptions returned in the body field.
|
||||||
They typically contain an additional message with details.
|
They typically contain an additional message with details.
|
||||||
All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability.
|
All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for better readability.
|
||||||
When sending a message, AttachmentInvalid means an attachment could not be read or uploaded, and the message
|
|
||||||
was not sent to anyone; Failure may mean that it reached some recipients.
|
|
||||||
|
|
||||||
Phone numbers always have the format +<countrycode><regional number>
|
Phone numbers always have the format +<countrycode><regional number>
|
||||||
|
|
||||||
For an account without a phone number, use its ACI (Account ID) with the
|
|
||||||
`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts.
|
|
||||||
An account without a phone number cannot infer the country code of a recipient;
|
|
||||||
use a complete international phone number, an ACI, or a Signal username.
|
|
||||||
|
|
||||||
== Methods
|
== Methods
|
||||||
|
|
||||||
=== SignalControl interface
|
=== SignalControl interface
|
||||||
@ -84,29 +73,11 @@ For example:
|
|||||||
dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256
|
dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256
|
||||||
----
|
----
|
||||||
|
|
||||||
listAccounts() -> accountList<ao>::
|
listAccounts() -> accountList<as>::
|
||||||
* accountList : Array of all attached accounts in DBus object path form
|
* accountList : Array of all attached accounts in DBus object path form
|
||||||
|
|
||||||
Exceptions: None
|
Exceptions: None
|
||||||
|
|
||||||
getAccount(identifier<s>) -> account<o>::
|
|
||||||
* identifier : Phone number or ACI of an attached account
|
|
||||||
* account : DBus object path of the account
|
|
||||||
|
|
||||||
Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown
|
|
||||||
|
|
||||||
startLink() -> deviceLinkUri<s>::
|
|
||||||
* deviceLinkUri : Provisioning URI to scan with the primary Signal device
|
|
||||||
|
|
||||||
finishLink(deviceLinkUri<s>, newDeviceName<s>) -> identifier<s>::
|
|
||||||
* deviceLinkUri : URI returned by startLink
|
|
||||||
* newDeviceName : Name for the linked device
|
|
||||||
* identifier : Phone number of the linked account, or its ACI when it has no phone number
|
|
||||||
|
|
||||||
Both linking methods support accounts with or without a phone number.
|
|
||||||
|
|
||||||
Exceptions: org.asamk.SignalControl.Error.Failure
|
|
||||||
|
|
||||||
register(number<s>, voiceVerification<b>) -> <>::
|
register(number<s>, voiceVerification<b>) -> <>::
|
||||||
* number : Phone number
|
* number : Phone number
|
||||||
* voiceVerification : true = use voice verification; false = use SMS verification
|
* voiceVerification : true = use voice verification; false = use SMS verification
|
||||||
@ -144,10 +115,9 @@ Exceptions: None
|
|||||||
|
|
||||||
The following methods listen to the account's object path, which is constructed as follows:
|
The following methods listen to the account's object path, which is constructed as follows:
|
||||||
|
|
||||||
- for single-account mode: "/org/asamk/Signal"
|
- for single-account mode: "/org/asamk/Signal/"
|
||||||
- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier
|
- for multi-account mode: "/org/asamk/Signal/" + DBusNumber
|
||||||
* DBusIdentifier: account's phone number with underscore (_) replacing plus (+),
|
* DBusNumber: account's phone number, with underscore (_) replacing plus (+)
|
||||||
or its ACI with underscores replacing hyphens when there is no phone number
|
|
||||||
|
|
||||||
getContactName(number<s>) -> name<s>::
|
getContactName(number<s>) -> name<s>::
|
||||||
* number : Phone number
|
* number : Phone number
|
||||||
@ -165,12 +135,7 @@ May result in e.g. two entries if a contact and profile name is set.
|
|||||||
Exceptions: None
|
Exceptions: None
|
||||||
|
|
||||||
getSelfNumber() -> number<s>::
|
getSelfNumber() -> number<s>::
|
||||||
* number : Your phone number, or an empty string for an account without a phone number
|
* number : Your phone number
|
||||||
|
|
||||||
Exceptions: None
|
|
||||||
|
|
||||||
getSelfACI() -> aci<s>::
|
|
||||||
* aci : Your account's ACI (Account ID), available with or without a phone number
|
|
||||||
|
|
||||||
Exceptions: None
|
Exceptions: None
|
||||||
|
|
||||||
@ -202,14 +167,6 @@ This is a concatenated list of all defined contacts as well of profiles known (e
|
|||||||
|
|
||||||
Exceptions: None
|
Exceptions: None
|
||||||
|
|
||||||
listRecipientIdentifiers() -> identifiers<as>::
|
|
||||||
* identifiers : String array of known recipient identifiers, including recipients without a phone number
|
|
||||||
|
|
||||||
Uses phone numbers where known and includes ACIs for recipients without a phone
|
|
||||||
number. Unlike `listNumbers`, this method also includes numberless recipients.
|
|
||||||
|
|
||||||
Exceptions: None
|
|
||||||
|
|
||||||
removePin() -> <>::
|
removePin() -> <>::
|
||||||
|
|
||||||
Removes registration PIN protection.
|
Removes registration PIN protection.
|
||||||
|
|||||||
@ -38,11 +38,6 @@ With `--http` signal-cli exposes three endpoints;
|
|||||||
* GET /api/v1/events : Returns a Server-Sent Events (SSE) stream of incoming messages
|
* GET /api/v1/events : Returns a Server-Sent Events (SSE) stream of incoming messages
|
||||||
* GET /api/v1/check : Responds with 200 OK if daemon is running
|
* GET /api/v1/check : Responds with 200 OK if daemon is running
|
||||||
|
|
||||||
Each event of `/api/v1/events` has an `id`.
|
|
||||||
signal-cli keeps the last 1000 events in memory.
|
|
||||||
A client that reconnects with a `Last-Event-ID` header first receives the events after that id.
|
|
||||||
If the id is unknown, e.g. because signal-cli was restarted, all kept events are sent.
|
|
||||||
|
|
||||||
== Basic usage
|
== Basic usage
|
||||||
|
|
||||||
In JSON-RPC mode, signal-cli will read requests from stdin.
|
In JSON-RPC mode, signal-cli will read requests from stdin.
|
||||||
@ -81,9 +76,6 @@ Messages are then sent similar to the automatic mode, but wrapped in a subscript
|
|||||||
When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts.
|
When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts.
|
||||||
|
|
||||||
In multi-account mode, requests for a single account require an additional `account` param.
|
In multi-account mode, requests for a single account require an additional `account` param.
|
||||||
For an account without a phone number, this parameter must be its ACI (Account ID).
|
|
||||||
`listAccounts` returns the ACI in `aci` and a null `number` for these accounts.
|
|
||||||
Receive events identify them by ACI in the `account` field.
|
|
||||||
|
|
||||||
REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}`
|
REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}`
|
||||||
|
|
||||||
@ -140,10 +132,7 @@ Params:
|
|||||||
|
|
||||||
REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}`
|
REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}`
|
||||||
|
|
||||||
RESPONSE: `{"jsonrpc":"2.0","result":{"number":null,"aci":"11111111-1111-4111-8111-111111111111"},"id":"6"}`
|
RESPONSE: `{"jsonrpc":"2.0","result":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X"},"id":"6"}`
|
||||||
|
|
||||||
The `number` field contains the phone number for a numbered account. Linking a numberless
|
|
||||||
account uses the same QR flow; no Account Key is supplied to `finishLink`.
|
|
||||||
|
|
||||||
== Examples
|
== Examples
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,7 @@ signal-cli - A commandline interface for the Signal messenger
|
|||||||
|
|
||||||
signal-cli is a commandline interface for libsignal-service-java.
|
signal-cli is a commandline interface for libsignal-service-java.
|
||||||
It supports registering, verifying, sending and receiving messages.
|
It supports registering, verifying, sending and receiving messages.
|
||||||
Accounts can be registered using a phone number that can receive SMS or incoming calls.
|
For registering you need a phone number where you can receive SMS or incoming calls.
|
||||||
Existing accounts can also be recovered using the Account Key and Recovery Key shown by Signal Android.
|
|
||||||
signal-cli was primarily developed to be used on servers to notify admins of important events.
|
signal-cli was primarily developed to be used on servers to notify admins of important events.
|
||||||
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
|
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
|
||||||
|
|
||||||
@ -66,8 +65,6 @@ Make sure you have full read/write access to the given directory.
|
|||||||
*-a* ACCOUNT, *--account* ACCOUNT::
|
*-a* ACCOUNT, *--account* ACCOUNT::
|
||||||
Specify your phone number, that will be your identifier.
|
Specify your phone number, that will be your identifier.
|
||||||
The phone number must include the country calling code, i.e. the number must start with a "+" sign.
|
The phone number must include the country calling code, i.e. the number must start with a "+" sign.
|
||||||
An ACI (the Account Key shown by Signal Android) can also select an account and is required for an account without a phone number.
|
|
||||||
The Account Key can be given as 32 hexadecimal characters or as a UUID with dashes.
|
|
||||||
|
|
||||||
This flag must not be given for the `link` command.
|
This flag must not be given for the `link` command.
|
||||||
It is optional for the `daemon` command.
|
It is optional for the `daemon` command.
|
||||||
@ -105,13 +102,12 @@ Disable message send log (for resending messages that recipient couldn't decrypt
|
|||||||
|
|
||||||
=== register
|
=== register
|
||||||
|
|
||||||
Register a phone number with SMS or voice verification, or recover an existing account using its Account Key and Recovery Key.
|
Register a phone number with SMS or voice verification.
|
||||||
Use the verify command to complete SMS or voice verification.
|
Use the verify command to complete the verification.
|
||||||
|
|
||||||
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
|
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
|
||||||
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
|
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
|
||||||
If the account was deleted (with --delete-account) it cannot be reactivated.
|
If the account was deleted (with --delete-account) it cannot be reactivated.
|
||||||
If normal recovery of an existing local account fails and its Account Key and Recovery Key are available locally, recovery using those credentials is attempted before SMS verification.
|
|
||||||
|
|
||||||
*-v*, *--voice*::
|
*-v*, *--voice*::
|
||||||
The verification should be done over voice, not SMS.
|
The verification should be done over voice, not SMS.
|
||||||
@ -124,23 +120,11 @@ For the staging environment, use: https://signalcaptchas.org/staging/registratio
|
|||||||
After solving the captcha, right-click on the "Open Signal" link and copy the link.
|
After solving the captcha, right-click on the "Open Signal" link and copy the link.
|
||||||
|
|
||||||
*--reregister*::
|
*--reregister*::
|
||||||
Register even if the local account is already registered.
|
Register even if account is already registered.
|
||||||
This option is required when using `--recovery-key` with an account that is still marked as registered locally.
|
|
||||||
|
|
||||||
*--recovery-key* RECOVERY-KEY::
|
|
||||||
Recover an existing account using the 64-character Recovery Key shown by Signal Android.
|
|
||||||
Specify the corresponding ACI (Account Key) with `-a`.
|
|
||||||
This option cannot be combined with `--voice` or `--captcha`.
|
|
||||||
|
|
||||||
*--totp* TOKEN::
|
|
||||||
The six-digit TOTP token requested during Account Key and Recovery Key based account recovery.
|
|
||||||
If automatic account recovery reports that a TOTP token is required, rerun `register` with both `--recovery-key` and `--totp`.
|
|
||||||
This option requires `--recovery-key`.
|
|
||||||
This option cannot be combined with `--voice` or `--captcha`.
|
|
||||||
|
|
||||||
=== verify
|
=== verify
|
||||||
|
|
||||||
Verify a number using the code received via SMS or voice.
|
Verify the number using the code received via SMS or voice.
|
||||||
|
|
||||||
VERIFICATIONCODE::
|
VERIFICATIONCODE::
|
||||||
The verification code.
|
The verification code.
|
||||||
@ -263,12 +247,7 @@ Remove the registration lock pin.
|
|||||||
Link to an existing device, instead of registering a new number.
|
Link to an existing device, instead of registering a new number.
|
||||||
This shows a "sgnl://linkdevice?uuid=..." URI.
|
This shows a "sgnl://linkdevice?uuid=..." URI.
|
||||||
If you want to connect to another signal-cli instance, you can just use this URI.
|
If you want to connect to another signal-cli instance, you can just use this URI.
|
||||||
If you want to link to an Android/iOS device, scan the QR code that signal-cli prints in the terminal with the Signal app.
|
If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
|
||||||
If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode).
|
|
||||||
|
|
||||||
Accounts without a phone number can also be linked.
|
|
||||||
After linking, use the account's ACI (Account Key) with `-a`; recipient phone numbers must include the international country code.
|
|
||||||
Existing primary accounts without a phone number can be recovered using the `register --recovery-key` command.
|
|
||||||
|
|
||||||
*-n* NAME, *--name* NAME::
|
*-n* NAME, *--name* NAME::
|
||||||
Optionally specify a name to describe this new device.
|
Optionally specify a name to describe this new device.
|
||||||
@ -365,15 +344,6 @@ Data URI encoded attachments must follow the RFC 2397.
|
|||||||
Additionally a file name can be added:
|
Additionally a file name can be added:
|
||||||
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
|
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
|
||||||
|
|
||||||
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
|
|
||||||
Specify the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`.
|
|
||||||
Use an empty string to skip one; image dimensions are otherwise detected automatically.
|
|
||||||
|
|
||||||
*--attachment-blurhash* [BLURHASH [BLURHASH ...]]::
|
|
||||||
Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading.
|
|
||||||
Use an empty string to skip one.
|
|
||||||
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'`
|
|
||||||
|
|
||||||
*--view-once*::
|
*--view-once*::
|
||||||
Send the message as a view once message.
|
Send the message as a view once message.
|
||||||
A conformant client will only allow the receiver to view the message once.
|
A conformant client will only allow the receiver to view the message once.
|
||||||
@ -1105,10 +1075,6 @@ Expose a JSON-RPC interface as http endpoint (default localhost:8080).
|
|||||||
The JSON-RPC endpoint is `/api/v1/rpc`. +
|
The JSON-RPC endpoint is `/api/v1/rpc`. +
|
||||||
See **signal-cli-jsonrpc**(5) for info on the JSON-RPC interface.
|
See **signal-cli-jsonrpc**(5) for info on the JSON-RPC interface.
|
||||||
|
|
||||||
*--sse-keepalive-interval* SECONDS::
|
|
||||||
Seconds between keep-alive messages on the http events endpoint `/api/v1/events` (default 15).
|
|
||||||
A shorter interval lets signal-cli notice sooner that a client has disconnected.
|
|
||||||
|
|
||||||
*--dbus*::
|
*--dbus*::
|
||||||
Export DBus interface on user bus. +
|
Export DBus interface on user bus. +
|
||||||
See **signal-cli-dbus**(5) for info on the dbus interface.
|
See **signal-cli-dbus**(5) for info on the dbus interface.
|
||||||
|
|||||||
@ -285,7 +285,6 @@ done
|
|||||||
|
|
||||||
run_main -a "$NUMBER_1" --output="$OUTPUT" receive
|
run_main -a "$NUMBER_1" --output="$OUTPUT" receive
|
||||||
run_main -a "$NUMBER_1" removeDevice -d 2 || true
|
run_main -a "$NUMBER_1" removeDevice -d 2 || true
|
||||||
run_main -a "$NUMBER_2" send "$NUMBER_1" -m "message-after-linked-device-removal"
|
|
||||||
run_main -a "$NUMBER_1" removeDevice -d 2 || true
|
run_main -a "$NUMBER_1" removeDevice -d 2 || true
|
||||||
|
|
||||||
## Unregister
|
## Unregister
|
||||||
|
|||||||
@ -20,9 +20,6 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public interface Signal extends DBusInterface {
|
public interface Signal extends DBusInterface {
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the account's phone number, or an empty string for a numberless account.
|
|
||||||
*/
|
|
||||||
String getSelfNumber();
|
String getSelfNumber();
|
||||||
|
|
||||||
String getSelfACI();
|
String getSelfACI();
|
||||||
@ -189,8 +186,6 @@ public interface Signal extends DBusInterface {
|
|||||||
|
|
||||||
List<String> listNumbers();
|
List<String> listNumbers();
|
||||||
|
|
||||||
List<String> listRecipientIdentifiers();
|
|
||||||
|
|
||||||
List<String> getContactNumber(final String name) throws Error.Failure;
|
List<String> getContactNumber(final String name) throws Error.Failure;
|
||||||
|
|
||||||
@Deprecated
|
@Deprecated
|
||||||
|
|||||||
@ -37,10 +37,7 @@ public interface SignalControl extends DBusInterface {
|
|||||||
|
|
||||||
List<DBusPath> listAccounts();
|
List<DBusPath> listAccounts();
|
||||||
|
|
||||||
/**
|
DBusPath getAccount(String number);
|
||||||
* Resolves a phone number or ACI to an exported account's object path.
|
|
||||||
*/
|
|
||||||
DBusPath getAccount(String identifier) throws Error.Failure;
|
|
||||||
|
|
||||||
interface Error {
|
interface Error {
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
package org.asamk.signal;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public final class AccountIdentifier {
|
|
||||||
|
|
||||||
private AccountIdentifier() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String normalize(final String identifier) {
|
|
||||||
if (identifier == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final var compact = identifier.replace("-", "").replaceAll("\\s", "").toLowerCase(Locale.ROOT);
|
|
||||||
if (compact.length() != 32 || !compact.matches("[0-9a-f]{32}")) {
|
|
||||||
return identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
final var uuid = "%s-%s-%s-%s-%s".formatted(compact.substring(0, 8),
|
|
||||||
compact.substring(8, 12),
|
|
||||||
compact.substring(12, 16),
|
|
||||||
compact.substring(16, 20),
|
|
||||||
compact.substring(20));
|
|
||||||
try {
|
|
||||||
return UUID.fromString(uuid).toString();
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return identifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -142,7 +142,7 @@ public class App {
|
|||||||
final var outputWriter = getOutputWriter(command);
|
final var outputWriter = getOutputWriter(command);
|
||||||
final var commandHandler = new CommandHandler(ns, outputWriter);
|
final var commandHandler = new CommandHandler(ns, outputWriter);
|
||||||
|
|
||||||
var account = AccountIdentifier.normalize(ns.getString("account"));
|
var account = ns.getString("account");
|
||||||
|
|
||||||
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
|
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
|
||||||
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
|
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
|
||||||
@ -187,8 +187,8 @@ public class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command instanceof RegistrationCommand registrationCommand) {
|
if (command instanceof RegistrationCommand registrationCommand) {
|
||||||
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
|
if (!Manager.isValidNumber(account, null)) {
|
||||||
throw new UserErrorException("Invalid account (E164 phone number or Account Key).");
|
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||||
}
|
}
|
||||||
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
|
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
|
||||||
return;
|
return;
|
||||||
@ -208,15 +208,14 @@ public class App {
|
|||||||
private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException {
|
private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException {
|
||||||
Set<String> accounts;
|
Set<String> accounts;
|
||||||
try {
|
try {
|
||||||
accounts = signalAccountFiles.getAllLocalAccountIdentifiers();
|
accounts = signalAccountFiles.getAllLocalAccountNumbers();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IOErrorException("Failed to load local accounts file", e);
|
throw new IOErrorException("Failed to load local accounts file", e);
|
||||||
}
|
}
|
||||||
if (accounts.isEmpty()) {
|
if (accounts.isEmpty()) {
|
||||||
throw new UserErrorException("No local users found, you first need to register or link an account");
|
throw new UserErrorException("No local users found, you first need to register or link an account");
|
||||||
} else if (accounts.size() > 1) {
|
} else if (accounts.size() > 1) {
|
||||||
throw new UserErrorException(
|
throw new UserErrorException("Multiple users found, you need to specify an account (phone number) with -a");
|
||||||
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
|
|
||||||
}
|
}
|
||||||
return accounts.stream().findFirst().get();
|
return accounts.stream().findFirst().get();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ public class BaseConfig {
|
|||||||
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
|
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
|
||||||
|
|
||||||
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
|
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
|
||||||
.orElse("Signal-Android/8.33.1");
|
.orElse("Signal-Android/8.27.0");
|
||||||
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
|
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
|
||||||
? "signal-cli"
|
? "signal-cli"
|
||||||
: PROJECT_NAME + "/" + PROJECT_VERSION;
|
: PROJECT_NAME + "/" + PROJECT_VERSION;
|
||||||
|
|||||||
@ -39,7 +39,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||||||
writer.println("Envelope from: {} (device: {}) to {}",
|
writer.println("Envelope from: {} (device: {}) to {}",
|
||||||
source.map(this::formatContact).orElse("unknown source"),
|
source.map(this::formatContact).orElse("unknown source"),
|
||||||
envelope.sourceDevice(),
|
envelope.sourceDevice(),
|
||||||
m.getSelfIdentifier());
|
m.getSelfNumber());
|
||||||
writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp()));
|
writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp()));
|
||||||
writer.println("Server timestamps: received: {} delivered: {}",
|
writer.println("Server timestamps: received: {} delivered: {}",
|
||||||
DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()),
|
DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()),
|
||||||
@ -55,13 +55,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||||||
final var recipientName = e.getSender().getLegacyIdentifier();
|
final var recipientName = e.getSender().getLegacyIdentifier();
|
||||||
writer.println(
|
writer.println(
|
||||||
"Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted",
|
"Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted",
|
||||||
m.getSelfIdentifier(),
|
m.getSelfNumber(),
|
||||||
recipientName,
|
recipientName,
|
||||||
m.getSelfIdentifier(),
|
m.getSelfNumber(),
|
||||||
recipientName);
|
recipientName);
|
||||||
writer.println(
|
writer.println(
|
||||||
"If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification",
|
"If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification",
|
||||||
m.getSelfIdentifier(),
|
m.getSelfNumber(),
|
||||||
recipientName);
|
recipientName);
|
||||||
} else {
|
} else {
|
||||||
writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName());
|
writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName());
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import java.net.InetSocketAddress;
|
|||||||
import java.net.UnixDomainSocketAddress;
|
import java.net.UnixDomainSocketAddress;
|
||||||
import java.nio.channels.Channel;
|
import java.nio.channels.Channel;
|
||||||
import java.nio.channels.ServerSocketChannel;
|
import java.nio.channels.ServerSocketChannel;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.SequencedCollection;
|
import java.util.SequencedCollection;
|
||||||
@ -70,10 +69,6 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
|||||||
.nargs("?")
|
.nargs("?")
|
||||||
.setConst("localhost:8080")
|
.setConst("localhost:8080")
|
||||||
.help("Expose a JSON-RPC interface as http endpoint (default localhost:8080).");
|
.help("Expose a JSON-RPC interface as http endpoint (default localhost:8080).");
|
||||||
subparser.addArgument("--sse-keepalive-interval")
|
|
||||||
.type(int.class)
|
|
||||||
.setDefault(15)
|
|
||||||
.help("Seconds between keep-alive messages on the http events endpoint (default 15).");
|
|
||||||
subparser.addArgument("--no-receive-stdout")
|
subparser.addArgument("--no-receive-stdout")
|
||||||
.help("Don’t print received messages to stdout.")
|
.help("Don’t print received messages to stdout.")
|
||||||
.action(Arguments.storeTrue());
|
.action(Arguments.storeTrue());
|
||||||
@ -193,11 +188,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
|||||||
final var httpAddress = ns.getString("http");
|
final var httpAddress = ns.getString("http");
|
||||||
if (httpAddress != null) {
|
if (httpAddress != null) {
|
||||||
final var address = IOUtils.parseInetSocketAddress(httpAddress);
|
final var address = IOUtils.parseInetSocketAddress(httpAddress);
|
||||||
final var keepAliveInterval = ns.getInt("sse-keepalive-interval");
|
daemonHandler.runHttp(address);
|
||||||
if (keepAliveInterval < 1) {
|
|
||||||
throw new UserErrorException("--sse-keepalive-interval must be at least 1 second.");
|
|
||||||
}
|
|
||||||
daemonHandler.runHttp(address, Duration.ofSeconds(keepAliveInterval));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system"));
|
final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system"));
|
||||||
@ -244,7 +235,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
|||||||
|
|
||||||
public abstract void runDbus(boolean isDbusSystem, final String busname) throws CommandException;
|
public abstract void runDbus(boolean isDbusSystem, final String busname) throws CommandException;
|
||||||
|
|
||||||
public abstract void runHttp(InetSocketAddress address, Duration keepAliveInterval) throws CommandException;
|
public abstract void runHttp(InetSocketAddress address) throws CommandException;
|
||||||
|
|
||||||
protected final void runSocket(final SocketHandler socketHandler) {
|
protected final void runSocket(final SocketHandler socketHandler) {
|
||||||
socketHandler.init();
|
socketHandler.init();
|
||||||
@ -300,8 +291,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException {
|
public void runHttp(InetSocketAddress address) throws CommandException {
|
||||||
runHttp(new HttpServerHandler(address, keepAliveInterval, m));
|
runHttp(new HttpServerHandler(address, m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,8 +316,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException {
|
public void runHttp(final InetSocketAddress address) throws CommandException {
|
||||||
runHttp(new HttpServerHandler(address, keepAliveInterval, c));
|
runHttp(new HttpServerHandler(address, c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,9 +54,9 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
|
|||||||
if (deviceName == null) {
|
if (deviceName == null) {
|
||||||
deviceName = "cli";
|
deviceName = "cli";
|
||||||
}
|
}
|
||||||
final String identifier;
|
final String number;
|
||||||
try {
|
try {
|
||||||
identifier = provisioningManager.finishDeviceLink(deviceName);
|
number = provisioningManager.finishDeviceLink(deviceName);
|
||||||
} catch (TimeoutException e) {
|
} catch (TimeoutException e) {
|
||||||
throw new UserErrorException("Link request timed out, please try again.");
|
throw new UserErrorException("Link request timed out, please try again.");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@ -73,11 +73,10 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
|
|||||||
+ "\" before trying again.");
|
+ "\" before trying again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final var manager = m.getManager(identifier);
|
jsonWriter.write(new JsonFinishLink(number));
|
||||||
jsonWriter.write(new JsonFinishLink(manager.getSelfNumber(), manager.getSelfACI()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
|
public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
|
||||||
|
|
||||||
private record JsonFinishLink(String number, String aci) {}
|
private record JsonFinishLink(String number) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,22 +27,19 @@ public class ListAccountsCommand implements JsonRpcMultiLocalCommand {
|
|||||||
final MultiAccountManager c,
|
final MultiAccountManager c,
|
||||||
final OutputWriter outputWriter
|
final OutputWriter outputWriter
|
||||||
) throws CommandException {
|
) throws CommandException {
|
||||||
final var managers = c.getManagers();
|
final var accountNumbers = c.getAccountNumbers();
|
||||||
switch (outputWriter) {
|
switch (outputWriter) {
|
||||||
case JsonWriter jsonWriter -> {
|
case JsonWriter jsonWriter -> {
|
||||||
final var jsonAccounts = managers.stream()
|
final var jsonAccounts = accountNumbers.stream().map(JsonAccount::new).toList();
|
||||||
.map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI()))
|
|
||||||
.toList();
|
|
||||||
jsonWriter.write(jsonAccounts);
|
jsonWriter.write(jsonAccounts);
|
||||||
}
|
}
|
||||||
case PlainTextWriter plainTextWriter -> {
|
case PlainTextWriter plainTextWriter -> {
|
||||||
for (final var manager : managers) {
|
for (final var number : accountNumbers) {
|
||||||
plainTextWriter.println(manager.getSelfNumber() != null ? "Number: {}" : "ACI: {}",
|
plainTextWriter.println("Number: {}", number);
|
||||||
manager.getSelfIdentifier());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private record JsonAccount(String number, String aci) {}
|
private record JsonAccount(String number) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import org.asamk.signal.manager.RegistrationManager;
|
|||||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||||
import org.asamk.signal.manager.api.RateLimitException;
|
import org.asamk.signal.manager.api.RateLimitException;
|
||||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
|
||||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||||
import org.asamk.signal.output.JsonWriter;
|
import org.asamk.signal.output.JsonWriter;
|
||||||
import org.asamk.signal.util.CommandUtil;
|
import org.asamk.signal.util.CommandUtil;
|
||||||
@ -42,8 +41,6 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
|||||||
subparser.addArgument("--reregister")
|
subparser.addArgument("--reregister")
|
||||||
.action(Arguments.storeTrue())
|
.action(Arguments.storeTrue())
|
||||||
.help("Register even if account is already registered");
|
.help("Register even if account is already registered");
|
||||||
subparser.addArgument("--recovery-key").help("Recover an account using its 64-character Signal Recovery Key.");
|
|
||||||
subparser.addArgument("--totp").help("A six-digit TOTP token required for account recovery.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -51,10 +48,8 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
|||||||
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
|
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
|
||||||
final var captcha = ns.getString("captcha");
|
final var captcha = ns.getString("captcha");
|
||||||
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
|
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
|
||||||
final var recoveryKey = ns.getString("recovery-key");
|
|
||||||
final var totp = ns.getString("totp");
|
|
||||||
|
|
||||||
register(m, voiceVerification, captcha, reregister, recoveryKey, totp);
|
register(m, voiceVerification, captcha, reregister);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -73,53 +68,15 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
|||||||
final RegistrationManager m,
|
final RegistrationManager m,
|
||||||
final JsonWriter jsonWriter
|
final JsonWriter jsonWriter
|
||||||
) throws CommandException {
|
) throws CommandException {
|
||||||
register(m,
|
register(m, Boolean.TRUE.equals(request.voice()), request.captcha(), Boolean.TRUE.equals(request.reregister()));
|
||||||
Boolean.TRUE.equals(request.voice()),
|
|
||||||
request.captcha(),
|
|
||||||
Boolean.TRUE.equals(request.reregister()),
|
|
||||||
request.recoveryKey(),
|
|
||||||
request.totp());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void register(
|
private void register(
|
||||||
final RegistrationManager m,
|
final RegistrationManager m,
|
||||||
final boolean voiceVerification,
|
final boolean voiceVerification,
|
||||||
final String captcha,
|
final String captcha,
|
||||||
final boolean reregister,
|
final boolean reregister
|
||||||
final String recoveryKey,
|
|
||||||
final String totpValue
|
|
||||||
) throws CommandException {
|
) throws CommandException {
|
||||||
if (recoveryKey == null && totpValue != null) {
|
|
||||||
throw new UserErrorException("--totp requires --recovery-key");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Integer totp;
|
|
||||||
if (totpValue == null) {
|
|
||||||
totp = null;
|
|
||||||
} else if (!totpValue.matches("[0-9]{6}")) {
|
|
||||||
throw new UserErrorException("TOTP token must contain exactly six digits");
|
|
||||||
} else {
|
|
||||||
totp = Integer.parseInt(totpValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recoveryKey != null) {
|
|
||||||
if (voiceVerification || captcha != null) {
|
|
||||||
throw new UserErrorException("--recovery-key cannot be combined with --voice or --captcha");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
m.registerWithRecoveryKey(recoveryKey, reregister, totp);
|
|
||||||
} catch (RateLimitException e) {
|
|
||||||
final var message = CommandUtil.getRateLimitMessage(e);
|
|
||||||
throw new RateLimitErrorException(message, e);
|
|
||||||
} catch (TwoFactorRequired e) {
|
|
||||||
throw new UserErrorException("A TOTP token is required; rerun register with --totp TOKEN");
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
|
||||||
e.getClass().getSimpleName()), e);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
m.register(voiceVerification, captcha, reregister);
|
m.register(voiceVerification, captcha, reregister);
|
||||||
} catch (RateLimitException e) {
|
} catch (RateLimitException e) {
|
||||||
@ -130,9 +87,6 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
|||||||
throw new UserErrorException(message);
|
throw new UserErrorException(message);
|
||||||
} catch (NonNormalizedPhoneNumberException e) {
|
} catch (NonNormalizedPhoneNumberException e) {
|
||||||
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
|
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
|
||||||
} catch (TwoFactorRequired e) {
|
|
||||||
throw new UserErrorException(
|
|
||||||
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
||||||
e.getClass().getSimpleName()), e);
|
e.getClass().getSimpleName()), e);
|
||||||
@ -145,7 +99,5 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegistrationParams(
|
public record RegistrationParams(Boolean voice, String captcha, Boolean reregister) {}
|
||||||
Boolean voice, String captcha, Boolean reregister, String recoveryKey, String totp
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,6 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
|
|||||||
public class SendCommand implements JsonRpcLocalCommand {
|
public class SendCommand implements JsonRpcLocalCommand {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(SendCommand.class);
|
private static final Logger logger = LoggerFactory.getLogger(SendCommand.class);
|
||||||
private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
@ -113,13 +112,6 @@ public class SendCommand implements JsonRpcLocalCommand {
|
|||||||
subparser.addArgument("--voice-note")
|
subparser.addArgument("--voice-note")
|
||||||
.action(Arguments.storeTrue())
|
.action(Arguments.storeTrue())
|
||||||
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
|
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
|
||||||
subparser.addArgument("--attachment-dimensions")
|
|
||||||
.nargs("*")
|
|
||||||
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
|
|
||||||
subparser.addArgument("--attachment-blurhash")
|
|
||||||
.nargs("*")
|
|
||||||
.help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show "
|
|
||||||
+ "while it's downloading. Use an empty string to skip one.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -183,13 +175,6 @@ public class SendCommand implements JsonRpcLocalCommand {
|
|||||||
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
|
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
|
||||||
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
|
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
|
||||||
|
|
||||||
final var dimensionStrings = ns.<String>getList("attachment-dimensions");
|
|
||||||
final var attachmentDimensions = dimensionStrings == null
|
|
||||||
? List.<Message.AttachmentDimensions>of()
|
|
||||||
: parseAttachmentDimensions(dimensionStrings);
|
|
||||||
final var blurHashes = ns.<String>getList("attachment-blurhash");
|
|
||||||
final var attachmentBlurHashes = blurHashes == null ? List.<String>of() : parseAttachmentBlurHashes(blurHashes);
|
|
||||||
|
|
||||||
final var selfNumber = m.getSelfNumber();
|
final var selfNumber = m.getSelfNumber();
|
||||||
|
|
||||||
final var mentionStrings = ns.<String>getList("mention");
|
final var mentionStrings = ns.<String>getList("mention");
|
||||||
@ -264,8 +249,6 @@ public class SendCommand implements JsonRpcLocalCommand {
|
|||||||
try {
|
try {
|
||||||
final var message = new Message(messageText,
|
final var message = new Message(messageText,
|
||||||
attachments,
|
attachments,
|
||||||
attachmentDimensions,
|
|
||||||
attachmentBlurHashes,
|
|
||||||
viewOnce,
|
viewOnce,
|
||||||
voiceNote,
|
voiceNote,
|
||||||
mentions,
|
mentions,
|
||||||
@ -297,53 +280,6 @@ public class SendCommand implements JsonRpcLocalCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
|
|
||||||
final List<String> dimensionStrings
|
|
||||||
) throws UserErrorException {
|
|
||||||
final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)");
|
|
||||||
final var dimensions = new ArrayList<Message.AttachmentDimensions>();
|
|
||||||
for (final var dimension : dimensionStrings) {
|
|
||||||
if (dimension.isEmpty()) {
|
|
||||||
dimensions.add(null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final var matcher = dimensionPattern.matcher(dimension);
|
|
||||||
if (!matcher.matches()) {
|
|
||||||
throw new UserErrorException("Invalid attachment dimensions syntax ("
|
|
||||||
+ dimension
|
|
||||||
+ ") expected 'WIDTHxHEIGHT'");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)),
|
|
||||||
Integer.parseInt(matcher.group(2))));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
throw new UserErrorException("Invalid attachment dimensions syntax ("
|
|
||||||
+ dimension
|
|
||||||
+ ") expected 'WIDTHxHEIGHT' with positive 32-bit integer values",
|
|
||||||
e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dimensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> parseAttachmentBlurHashes(final List<String> blurHashes) throws UserErrorException {
|
|
||||||
for (final var blurHash : blurHashes) {
|
|
||||||
if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) {
|
|
||||||
throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return blurHashes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same check the BlurHash decoders make: the first digit fixes the length.
|
|
||||||
private static boolean isValidBlurHash(final String blurHash) {
|
|
||||||
if (blurHash.length() < 6) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0));
|
|
||||||
return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Message.Mention> parseMentions(
|
private List<Message.Mention> parseMentions(
|
||||||
final String selfNumber,
|
final String selfNumber,
|
||||||
final List<String> mentionStrings
|
final List<String> mentionStrings
|
||||||
|
|||||||
@ -55,7 +55,7 @@ public class DbusCommandHandler {
|
|||||||
try {
|
try {
|
||||||
if (command instanceof ProvisioningCommand c) {
|
if (command instanceof ProvisioningCommand c) {
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
throw new UserErrorException("You cannot specify an account when linking");
|
throw new UserErrorException("You cannot specify a account (phone number) when linking");
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProvisioningCommand(c, dBusConn, busname, commandHandler);
|
handleProvisioningCommand(c, dBusConn, busname, commandHandler);
|
||||||
@ -74,9 +74,7 @@ public class DbusCommandHandler {
|
|||||||
throw new UserErrorException("Command only works in multi-account mode");
|
throw new UserErrorException("Command only works in multi-account mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
var accountObjectPath = account == null
|
var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null;
|
||||||
? tryGetSingleAccountObjectPath(dBusConn, busname)
|
|
||||||
: getAccountObjectPath(account, dBusConn, busname);
|
|
||||||
if (accountObjectPath == null) {
|
if (accountObjectPath == null) {
|
||||||
accountObjectPath = DbusConfig.getObjectPath(account);
|
accountObjectPath = DbusConfig.getObjectPath(account);
|
||||||
}
|
}
|
||||||
@ -88,24 +86,6 @@ public class DbusCommandHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getAccountObjectPath(
|
|
||||||
final String account,
|
|
||||||
final DBusConnection dBusConn,
|
|
||||||
final String busname
|
|
||||||
) throws DBusException, CommandException {
|
|
||||||
final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
|
|
||||||
try {
|
|
||||||
return control.getAccount(account).getPath();
|
|
||||||
} catch (UnknownMethod e) {
|
|
||||||
// A single-account daemon exports Signal directly at the base path.
|
|
||||||
final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
|
|
||||||
if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) {
|
|
||||||
throw new UserErrorException("Unknown account: " + account);
|
|
||||||
}
|
|
||||||
return DbusConfig.getObjectPath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String tryGetSingleAccountObjectPath(
|
private static String tryGetSingleAccountObjectPath(
|
||||||
final DBusConnection dBusConn,
|
final DBusConnection dBusConn,
|
||||||
final String busname
|
final String busname
|
||||||
@ -117,7 +97,7 @@ public class DbusCommandHandler {
|
|||||||
throw new UserErrorException("No local users found, you first need to register or link an account");
|
throw new UserErrorException("No local users found, you first need to register or link an account");
|
||||||
} else if (accounts.size() > 1) {
|
} else if (accounts.size() > 1) {
|
||||||
throw new UserErrorException(
|
throw new UserErrorException(
|
||||||
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
|
"Multiple users found, you need to specify an account (phone number) with -a");
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts.getFirst().getPath();
|
return accounts.getFirst().getPath();
|
||||||
|
|||||||
@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
c.addOnManagerRemovedHandler(m -> {
|
c.addOnManagerRemovedHandler(m -> {
|
||||||
final var path = DbusConfig.getObjectPath(m.getSelfIdentifier());
|
final var path = DbusConfig.getObjectPath(m.getSelfNumber());
|
||||||
try {
|
try {
|
||||||
final var object = connection.getExportedObject(null, path);
|
final var object = connection.getExportedObject(null, path);
|
||||||
if (object instanceof DbusSignalImpl dbusSignal) {
|
if (object instanceof DbusSignalImpl dbusSignal) {
|
||||||
@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable {
|
|||||||
final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart);
|
final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart);
|
||||||
closeables.add(signal);
|
closeables.add(signal);
|
||||||
|
|
||||||
return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects);
|
return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Thread exportManager(final DBusConnection conn, final Manager m) {
|
private Thread exportManager(final DBusConnection conn, final Manager m) {
|
||||||
final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier());
|
final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber());
|
||||||
return exportDbusObject(conn, objectPath, m);
|
return exportDbusObject(conn, objectPath, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,6 @@ import java.util.Map;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
@ -111,8 +110,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getSelfNumber() {
|
public String getSelfNumber() {
|
||||||
final var number = signal.getSelfNumber();
|
return signal.getSelfNumber();
|
||||||
return number.isEmpty() ? null : number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -440,7 +438,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP));
|
numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP));
|
||||||
return 0L;
|
return 0L;
|
||||||
}, () -> {
|
}, () -> {
|
||||||
signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP);
|
signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP);
|
||||||
return 0L;
|
return 0L;
|
||||||
}, groupId -> {
|
}, groupId -> {
|
||||||
signal.sendGroupTyping(groupId, action == TypingAction.STOP);
|
signal.sendGroupTyping(groupId, action == TypingAction.STOP);
|
||||||
@ -488,7 +486,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
||||||
return handleMessage(recipients,
|
return handleMessage(recipients,
|
||||||
numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers),
|
numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers),
|
||||||
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()),
|
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()),
|
||||||
groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId));
|
groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -512,7 +510,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
remove,
|
remove,
|
||||||
targetAuthor.getIdentifier(),
|
targetAuthor.getIdentifier(),
|
||||||
targetSentTimestamp,
|
targetSentTimestamp,
|
||||||
getSelfIdentifier()),
|
signal.getSelfNumber()),
|
||||||
groupId -> signal.sendGroupMessageReaction(emoji,
|
groupId -> signal.sendGroupMessageReaction(emoji,
|
||||||
remove,
|
remove,
|
||||||
targetAuthor.getIdentifier(),
|
targetAuthor.getIdentifier(),
|
||||||
@ -811,13 +809,11 @@ public class DbusManagerImpl implements Manager {
|
|||||||
final Collection<RecipientIdentifier.Single> addresses,
|
final Collection<RecipientIdentifier.Single> addresses,
|
||||||
final Optional<String> name
|
final Optional<String> name
|
||||||
) {
|
) {
|
||||||
final var identifiers = addresses.stream()
|
final var numbers = addresses.stream()
|
||||||
.map(RecipientIdentifier.Single::getIdentifier)
|
.filter(s -> s instanceof RecipientIdentifier.Number)
|
||||||
|
.map(s -> ((RecipientIdentifier.Number) s).number())
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
return signal.listRecipientIdentifiers()
|
return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> {
|
||||||
.stream()
|
|
||||||
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
|
|
||||||
.map(n -> {
|
|
||||||
final var contactBlocked = signal.isContactBlocked(n);
|
final var contactBlocked = signal.isContactBlocked(n);
|
||||||
if (blocked.isPresent() && blocked.get() != contactBlocked) {
|
if (blocked.isPresent() && blocked.get() != contactBlocked) {
|
||||||
return null;
|
return null;
|
||||||
@ -830,7 +826,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Recipient.newBuilder()
|
return Recipient.newBuilder()
|
||||||
.withAddress(getRecipientAddress(n))
|
.withAddress(new RecipientAddress(n))
|
||||||
.withContact(new Contact(contactName,
|
.withContact(new Contact(contactName,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -849,9 +845,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
false,
|
false,
|
||||||
null))
|
null))
|
||||||
.build();
|
.build();
|
||||||
})
|
}).filter(Objects::nonNull).toList();
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -876,16 +870,16 @@ public class DbusManagerImpl implements Manager {
|
|||||||
(String) group.get("Description").getValue(),
|
(String) group.get("Description").getValue(),
|
||||||
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
|
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
|
||||||
((List<String>) group.get("Members").getValue()).stream()
|
((List<String>) group.get("Members").getValue()).stream()
|
||||||
.map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null))
|
.map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
|
||||||
.collect(Collectors.toSet()),
|
.collect(Collectors.toSet()),
|
||||||
((List<String>) group.get("PendingMembers").getValue()).stream()
|
((List<String>) group.get("PendingMembers").getValue()).stream()
|
||||||
.map(DbusManagerImpl::getRecipientAddress)
|
.map(RecipientAddress::new)
|
||||||
.collect(Collectors.toSet()),
|
.collect(Collectors.toSet()),
|
||||||
((List<String>) group.get("RequestingMembers").getValue()).stream()
|
((List<String>) group.get("RequestingMembers").getValue()).stream()
|
||||||
.map(DbusManagerImpl::getRecipientAddress)
|
.map(RecipientAddress::new)
|
||||||
.collect(Collectors.toSet()),
|
.collect(Collectors.toSet()),
|
||||||
((List<String>) group.get("Banned").getValue()).stream()
|
((List<String>) group.get("Banned").getValue()).stream()
|
||||||
.map(DbusManagerImpl::getRecipientAddress)
|
.map(RecipientAddress::new)
|
||||||
.collect(Collectors.toSet()),
|
.collect(Collectors.toSet()),
|
||||||
(boolean) group.get("IsBlocked").getValue(),
|
(boolean) group.get("IsBlocked").getValue(),
|
||||||
(int) group.get("MessageExpirationTimer").getValue(),
|
(int) group.get("MessageExpirationTimer").getValue(),
|
||||||
@ -916,10 +910,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
|
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
|
||||||
final var aci = (String) group.get("Uuid").getValue();
|
final var aci = (String) group.get("Uuid").getValue();
|
||||||
final var number = (String) group.get("Number").getValue();
|
final var number = (String) group.get("Number").getValue();
|
||||||
return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci,
|
return new Identity(new RecipientAddress(aci, null, number, null),
|
||||||
null,
|
|
||||||
number.isEmpty() ? null : number,
|
|
||||||
null),
|
|
||||||
(byte[]) group.get("Fingerprint").getValue(),
|
(byte[]) group.get("Fingerprint").getValue(),
|
||||||
(String) group.get("SafetyNumber").getValue(),
|
(String) group.get("SafetyNumber").getValue(),
|
||||||
(byte[]) group.get("ScannableSafetyNumber").getValue(),
|
(byte[]) group.get("ScannableSafetyNumber").getValue(),
|
||||||
@ -1082,14 +1073,6 @@ public class DbusManagerImpl implements Manager {
|
|||||||
return string == null ? "" : string;
|
return string == null ? "" : string;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RecipientAddress getRecipientAddress(final String identifier) {
|
|
||||||
try {
|
|
||||||
return new RecipientAddress(UUID.fromString(identifier));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return new RecipientAddress(identifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
|
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
|
||||||
try {
|
try {
|
||||||
return connection.getRemoteObject(busname, path.getPath(), type);
|
return connection.getRemoteObject(busname, path.getPath(), type);
|
||||||
@ -1102,7 +1085,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
try {
|
try {
|
||||||
this.dbusMsgHandler = messageReceived -> {
|
this.dbusMsgHandler = messageReceived -> {
|
||||||
final var extras = messageReceived.getExtras();
|
final var extras = messageReceived.getExtras();
|
||||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
|
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
|
||||||
0,
|
0,
|
||||||
messageReceived.getTimestamp(),
|
messageReceived.getTimestamp(),
|
||||||
0,
|
0,
|
||||||
@ -1149,7 +1132,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler);
|
connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler);
|
||||||
this.dbusEditMsgHandler = messageReceived -> {
|
this.dbusEditMsgHandler = messageReceived -> {
|
||||||
final var extras = messageReceived.getExtras();
|
final var extras = messageReceived.getExtras();
|
||||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
|
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
|
||||||
0,
|
0,
|
||||||
messageReceived.getTimestamp(),
|
messageReceived.getTimestamp(),
|
||||||
0,
|
0,
|
||||||
@ -1203,7 +1186,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY;
|
case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY;
|
||||||
default -> MessageEnvelope.Receipt.Type.UNKNOWN;
|
default -> MessageEnvelope.Receipt.Type.UNKNOWN;
|
||||||
};
|
};
|
||||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())),
|
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())),
|
||||||
0,
|
0,
|
||||||
receiptReceived.getTimestamp(),
|
receiptReceived.getTimestamp(),
|
||||||
0,
|
0,
|
||||||
@ -1224,7 +1207,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
|
|
||||||
this.dbusSyncHandler = syncReceived -> {
|
this.dbusSyncHandler = syncReceived -> {
|
||||||
final var extras = syncReceived.getExtras();
|
final var extras = syncReceived.getExtras();
|
||||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())),
|
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())),
|
||||||
0,
|
0,
|
||||||
syncReceived.getTimestamp(),
|
syncReceived.getTimestamp(),
|
||||||
0,
|
0,
|
||||||
@ -1238,7 +1221,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
syncReceived.getTimestamp(),
|
syncReceived.getTimestamp(),
|
||||||
syncReceived.getDestination().isEmpty()
|
syncReceived.getDestination().isEmpty()
|
||||||
? Optional.empty()
|
? Optional.empty()
|
||||||
: Optional.of(getRecipientAddress(syncReceived.getDestination())),
|
: Optional.of(new RecipientAddress(syncReceived.getDestination())),
|
||||||
Set.of(),
|
Set.of(),
|
||||||
Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(),
|
Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(),
|
||||||
syncReceived.getGroupId().length > 0
|
syncReceived.getGroupId().length > 0
|
||||||
@ -1343,7 +1326,7 @@ public class DbusManagerImpl implements Manager {
|
|||||||
|
|
||||||
final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
|
final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
|
||||||
return mentions.stream()
|
return mentions.stream()
|
||||||
.map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.<String>getValue(a, "recipient")),
|
.map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.<String>getValue(a, "recipient")),
|
||||||
getValue(a, "start"),
|
getValue(a, "start"),
|
||||||
getValue(a, "length")))
|
getValue(a, "length")))
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -43,6 +43,14 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
this.busname = busname;
|
this.busname = busname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAccountNumbers() {
|
||||||
|
return signalControl.listAccounts()
|
||||||
|
.stream()
|
||||||
|
.map(a -> getRemoteObject(a, Signal.class).getSelfNumber())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Manager> getManagers() {
|
public List<Manager> getManagers() {
|
||||||
return signalControl.listAccounts()
|
return signalControl.listAccounts()
|
||||||
@ -58,13 +66,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
|
|
||||||
synchronized (onManagerAddedHandlers) {
|
|
||||||
onManagerAddedHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
public void addOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
||||||
synchronized (onManagerRemovedHandlers) {
|
synchronized (onManagerRemovedHandlers) {
|
||||||
@ -73,15 +74,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
public Manager getManager(final String phoneNumber) {
|
||||||
synchronized (onManagerRemovedHandlers) {
|
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class),
|
||||||
onManagerRemovedHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Manager getManager(final String identifier) {
|
|
||||||
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class),
|
|
||||||
connection,
|
connection,
|
||||||
busname);
|
busname);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,10 +165,7 @@ public class DbusReceiveMessageHandler implements Manager.ReceiveMessageHandler
|
|||||||
}
|
}
|
||||||
if (message.remoteDeleteId().isPresent()) {
|
if (message.remoteDeleteId().isPresent()) {
|
||||||
extras.put("remoteDelete",
|
extras.put("remoteDelete",
|
||||||
new Variant<>(Map.of("targetTimestamp",
|
new Variant<>(Map.of("timestamp", new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
|
||||||
new Variant<>(message.remoteDeleteId().get()),
|
|
||||||
"timestamp",
|
|
||||||
new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
|
|
||||||
}
|
}
|
||||||
if (message.sticker().isPresent()) {
|
if (message.sticker().isPresent()) {
|
||||||
final var sticker = message.sticker().get();
|
final var sticker = message.sticker().get();
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.asamk.signal.manager.RegistrationManager;
|
|||||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||||
import org.asamk.signal.manager.api.PinLockedException;
|
import org.asamk.signal.manager.api.PinLockedException;
|
||||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
|
||||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -54,15 +53,6 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerWithRecoveryKey(
|
|
||||||
final String recoveryKey,
|
|
||||||
final boolean forceRegister,
|
|
||||||
final Integer totp
|
|
||||||
) throws IOException, TwoFactorRequired {
|
|
||||||
throw new IOException("Recovery-key registration is not supported over D-Bus");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteLocalAccountData() throws IOException {
|
public void deleteLocalAccountData() throws IOException {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
|
|||||||
@ -159,19 +159,11 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<DBusPath> listAccounts() {
|
public List<DBusPath> listAccounts() {
|
||||||
return c.getManagers()
|
return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList();
|
||||||
.stream()
|
|
||||||
.map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier())))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DBusPath getAccount(final String identifier) {
|
public DBusPath getAccount(final String number) {
|
||||||
final var manager = c.getManagers()
|
return new DBusPath(DbusConfig.getObjectPath(number));
|
||||||
.stream()
|
|
||||||
.filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI()))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new Error.Failure("Unknown account: " + identifier));
|
|
||||||
return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getSelfNumber() {
|
public String getSelfNumber() {
|
||||||
return emptyIfNull(m.getSelfNumber());
|
return m.getSelfNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -242,8 +242,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
|||||||
try {
|
try {
|
||||||
final var message = new Message(messageText,
|
final var message = new Message(messageText,
|
||||||
attachments,
|
attachments,
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
List.of(),
|
List.of(),
|
||||||
@ -411,8 +409,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
|||||||
try {
|
try {
|
||||||
final var message = new Message(messageText,
|
final var message = new Message(messageText,
|
||||||
attachments,
|
attachments,
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
List.of(),
|
List.of(),
|
||||||
@ -460,8 +456,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
|||||||
try {
|
try {
|
||||||
final var message = new Message(messageText,
|
final var message = new Message(messageText,
|
||||||
attachments,
|
attachments,
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
List.of(),
|
List.of(),
|
||||||
@ -803,15 +797,6 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<String> listRecipientIdentifiers() {
|
|
||||||
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
|
|
||||||
.stream()
|
|
||||||
.map(r -> r.getAddress().getLegacyIdentifier())
|
|
||||||
.distinct()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getContactNumber(final String name) {
|
public List<String> getContactNumber(final String name) {
|
||||||
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))
|
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))
|
||||||
|
|||||||
@ -5,13 +5,13 @@ import com.sun.net.httpserver.HttpExchange;
|
|||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
import org.asamk.signal.commands.Commands;
|
import org.asamk.signal.commands.Commands;
|
||||||
|
import org.asamk.signal.json.JsonReceiveMessageHandler;
|
||||||
import org.asamk.signal.jsonrpc.JsonRpcReader;
|
import org.asamk.signal.jsonrpc.JsonRpcReader;
|
||||||
import org.asamk.signal.jsonrpc.JsonRpcResponse;
|
import org.asamk.signal.jsonrpc.JsonRpcResponse;
|
||||||
import org.asamk.signal.jsonrpc.JsonRpcSender;
|
import org.asamk.signal.jsonrpc.JsonRpcSender;
|
||||||
import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler;
|
import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler;
|
||||||
import org.asamk.signal.manager.Manager;
|
import org.asamk.signal.manager.Manager;
|
||||||
import org.asamk.signal.manager.MultiAccountManager;
|
import org.asamk.signal.manager.MultiAccountManager;
|
||||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
|
||||||
import org.asamk.signal.manager.api.Pair;
|
import org.asamk.signal.manager.api.Pair;
|
||||||
import org.asamk.signal.util.Util;
|
import org.asamk.signal.util.Util;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -19,7 +19,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@ -35,7 +34,6 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
private final ObjectMapper objectMapper = Util.createJsonObjectMapper();
|
private final ObjectMapper objectMapper = Util.createJsonObjectMapper();
|
||||||
|
|
||||||
private final InetSocketAddress address;
|
private final InetSocketAddress address;
|
||||||
private final Duration keepAliveInterval;
|
|
||||||
|
|
||||||
private final SignalJsonRpcCommandHandler commandHandler;
|
private final SignalJsonRpcCommandHandler commandHandler;
|
||||||
private final MultiAccountManager c;
|
private final MultiAccountManager c;
|
||||||
@ -43,24 +41,17 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
private HttpServer server;
|
private HttpServer server;
|
||||||
private final AtomicBoolean shutdown = new AtomicBoolean(false);
|
private final AtomicBoolean shutdown = new AtomicBoolean(false);
|
||||||
private final Set<String> allowedHosts;
|
private final Set<String> allowedHosts;
|
||||||
private final SseEventBuffer eventBuffer = new SseEventBuffer(objectMapper);
|
|
||||||
|
|
||||||
public HttpServerHandler(final InetSocketAddress address, final Duration keepAliveInterval, final Manager m) {
|
public HttpServerHandler(final InetSocketAddress address, final Manager m) {
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.keepAliveInterval = keepAliveInterval;
|
|
||||||
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
|
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
|
||||||
this.c = null;
|
this.c = null;
|
||||||
this.m = m;
|
this.m = m;
|
||||||
this.allowedHosts = buildAllowedHosts(address);
|
this.allowedHosts = buildAllowedHosts(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpServerHandler(
|
public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) {
|
||||||
final InetSocketAddress address,
|
|
||||||
final Duration keepAliveInterval,
|
|
||||||
final MultiAccountManager c
|
|
||||||
) {
|
|
||||||
this.address = address;
|
this.address = address;
|
||||||
this.keepAliveInterval = keepAliveInterval;
|
|
||||||
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
|
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
|
||||||
this.c = c;
|
this.c = c;
|
||||||
this.m = null;
|
this.m = null;
|
||||||
@ -102,7 +93,6 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
// Increase this delay when https://bugs.openjdk.org/browse/JDK-8304065 is fixed
|
// Increase this delay when https://bugs.openjdk.org/browse/JDK-8304065 is fixed
|
||||||
server.stop(2);
|
server.stop(2);
|
||||||
server = null;
|
server = null;
|
||||||
eventBuffer.close();
|
|
||||||
shutdown.set(false);
|
shutdown.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,27 +199,22 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
|
|
||||||
// Flush HTTP response headers to the client immediately.
|
// Flush HTTP response headers to the client immediately.
|
||||||
// Without this, the JVM HttpServer buffers everything until a later write
|
// Without this, the JVM HttpServer buffers everything until a later write
|
||||||
// in the keep-alive loop, causing clients with shorter timeouts
|
// in the keep-alive loop (15 s), causing clients with shorter timeouts
|
||||||
// (e.g. 10 s) to abort before receiving the initial response.
|
// (e.g. 10 s) to abort before receiving the initial response.
|
||||||
httpExchange.getResponseBody().flush();
|
httpExchange.getResponseBody().flush();
|
||||||
|
|
||||||
final var shouldStop = new AtomicBoolean(false);
|
final var shouldStop = new AtomicBoolean(false);
|
||||||
managers.forEach(eventBuffer::record);
|
final var handlers = subscribeReceiveHandlers(managers, sender, () -> {
|
||||||
final var client = eventBuffer.connect(managers,
|
|
||||||
sender,
|
|
||||||
httpExchange.getRequestHeaders().getFirst("Last-Event-ID"),
|
|
||||||
() -> {
|
|
||||||
shouldStop.set(true);
|
shouldStop.set(true);
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
this.notifyAll();
|
this.notifyAll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final var handlers = subscribeReceiveHandlers(managers);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
wait(keepAliveInterval.toMillis());
|
wait(15_000);
|
||||||
}
|
}
|
||||||
if (shouldStop.get() || shutdown.get()) {
|
if (shouldStop.get() || shutdown.get()) {
|
||||||
break;
|
break;
|
||||||
@ -242,7 +227,6 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
eventBuffer.disconnect(client);
|
|
||||||
for (final var pair : handlers) {
|
for (final var pair : handlers) {
|
||||||
unsubscribeReceiveHandler(pair);
|
unsubscribeReceiveHandler(pair);
|
||||||
}
|
}
|
||||||
@ -292,17 +276,19 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
throw new AssertionError("Unreachable state");
|
throw new AssertionError("Unreachable state");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(
|
||||||
* The events are sent by eventBuffer, these handlers only keep the managers receiving while the client is connected.
|
final List<Manager> managers,
|
||||||
*/
|
final ServerSentEventSender sender,
|
||||||
private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(final List<Manager> managers) {
|
Callable unsubscribe
|
||||||
|
) {
|
||||||
return managers.stream().map(m1 -> {
|
return managers.stream().map(m1 -> {
|
||||||
// An anonymous class instead of a lambda, as every connection needs its own handler instance
|
final var receiveMessageHandler = new JsonReceiveMessageHandler(m1, s -> {
|
||||||
final var receiveMessageHandler = new Manager.ReceiveMessageHandler() {
|
try {
|
||||||
@Override
|
sender.sendEvent(null, "receive", List.of(objectMapper.writeValueAsString(s)));
|
||||||
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
|
} catch (IOException e) {
|
||||||
|
unsubscribe.call();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
m1.addReceiveHandler(receiveMessageHandler);
|
m1.addReceiveHandler(receiveMessageHandler);
|
||||||
return new Pair<>(m1, (Manager.ReceiveMessageHandler) receiveMessageHandler);
|
return new Pair<>(m1, (Manager.ReceiveMessageHandler) receiveMessageHandler);
|
||||||
}).toList();
|
}).toList();
|
||||||
@ -314,6 +300,11 @@ public class HttpServerHandler implements AutoCloseable {
|
|||||||
m.removeReceiveHandler(handler);
|
m.removeReceiveHandler(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private interface Callable {
|
||||||
|
|
||||||
|
void call();
|
||||||
|
}
|
||||||
|
|
||||||
private Set<String> buildAllowedHosts(final InetSocketAddress address) {
|
private Set<String> buildAllowedHosts(final InetSocketAddress address) {
|
||||||
final var s = new HashSet<String>();
|
final var s = new HashSet<String>();
|
||||||
final var host = address == null ? null : address.getHostString();
|
final var host = address == null ? null : address.getHostString();
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
package org.asamk.signal.http;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import org.asamk.signal.json.JsonReceiveMessageHandler;
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the most recent SSE events and sends new events to the connected clients.
|
|
||||||
* A client that reconnects with a Last-Event-ID header first gets the buffered events it missed.
|
|
||||||
* <p>
|
|
||||||
* Event ids have the form "{start time}-{sequence}", so an id from an earlier daemon run never matches.
|
|
||||||
*/
|
|
||||||
final class SseEventBuffer implements AutoCloseable {
|
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(SseEventBuffer.class);
|
|
||||||
|
|
||||||
static final int MAX_EVENTS = 1000;
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final String idPrefix = System.currentTimeMillis() + "-";
|
|
||||||
private final Map<Manager, Manager.ReceiveMessageHandler> recorders = new HashMap<>();
|
|
||||||
private final ArrayDeque<Event> events = new ArrayDeque<>();
|
|
||||||
private final List<Client> clients = new ArrayList<>();
|
|
||||||
private long nextSequence = 1;
|
|
||||||
|
|
||||||
SseEventBuffer(final ObjectMapper objectMapper) {
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start recording the messages of the given account.
|
|
||||||
* The handler is registered as a weak listener, so it never starts receiving by itself.
|
|
||||||
*/
|
|
||||||
void record(final Manager m) {
|
|
||||||
synchronized (recorders) {
|
|
||||||
if (recorders.containsKey(m)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var handler = new JsonReceiveMessageHandler(m, s -> add(m, s));
|
|
||||||
m.addReceiveHandler(handler, true);
|
|
||||||
recorders.put(m, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a client for the given accounts.
|
|
||||||
* If lastEventId is set, the buffered events after it are sent first.
|
|
||||||
* An unknown id (too old, or from an earlier run) sends all buffered events.
|
|
||||||
*/
|
|
||||||
synchronized Client connect(
|
|
||||||
final Collection<Manager> managers,
|
|
||||||
final ServerSentEventSender sender,
|
|
||||||
final String lastEventId,
|
|
||||||
final Runnable onClosed
|
|
||||||
) {
|
|
||||||
final var client = new Client(managers, sender, onClosed);
|
|
||||||
if (lastEventId != null) {
|
|
||||||
final var lastSequence = getSequence(lastEventId);
|
|
||||||
for (final var event : events) {
|
|
||||||
if (event.sequence() > lastSequence && managers.contains(event.manager())) {
|
|
||||||
client.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clients.add(client);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized void disconnect(final Client client) {
|
|
||||||
clients.remove(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
synchronized (recorders) {
|
|
||||||
recorders.forEach(Manager::removeReceiveHandler);
|
|
||||||
recorders.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void add(final Manager m, final Object message) {
|
|
||||||
final String data;
|
|
||||||
try {
|
|
||||||
data = objectMapper.writeValueAsString(message);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to serialize received message, ignoring", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Event event;
|
|
||||||
final List<Client> receivers;
|
|
||||||
synchronized (this) {
|
|
||||||
event = new Event(nextSequence++, m, data);
|
|
||||||
events.addLast(event);
|
|
||||||
if (events.size() > MAX_EVENTS) {
|
|
||||||
events.removeFirst();
|
|
||||||
}
|
|
||||||
receivers = clients.stream().filter(c -> c.managers.contains(m)).toList();
|
|
||||||
}
|
|
||||||
// Sent outside the lock, so a slow client doesn't block the other accounts
|
|
||||||
for (final var client : receivers) {
|
|
||||||
client.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private long getSequence(final String eventId) {
|
|
||||||
if (eventId.startsWith(idPrefix)) {
|
|
||||||
try {
|
|
||||||
return Long.parseLong(eventId.substring(idPrefix.length()));
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Event(long sequence, Manager manager, String data) {}
|
|
||||||
|
|
||||||
final class Client {
|
|
||||||
|
|
||||||
private final Collection<Manager> managers;
|
|
||||||
private final ServerSentEventSender sender;
|
|
||||||
private final Runnable onClosed;
|
|
||||||
|
|
||||||
private Client(
|
|
||||||
final Collection<Manager> managers,
|
|
||||||
final ServerSentEventSender sender,
|
|
||||||
final Runnable onClosed
|
|
||||||
) {
|
|
||||||
this.managers = managers;
|
|
||||||
this.sender = sender;
|
|
||||||
this.onClosed = onClosed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(final Event event) {
|
|
||||||
try {
|
|
||||||
sender.sendEvent(idPrefix + event.sequence(), "receive", List.of(event.data()));
|
|
||||||
} catch (IOException e) {
|
|
||||||
onClosed.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,11 +8,7 @@ import io.micronaut.jsonschema.JsonSchema;
|
|||||||
|
|
||||||
@JsonSchema(title = "AdminDelete")
|
@JsonSchema(title = "AdminDelete")
|
||||||
public record JsonAdminDelete(
|
public record JsonAdminDelete(
|
||||||
@Deprecated String targetAuthor,
|
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
|
||||||
String targetAuthorNumber,
|
|
||||||
String targetAuthorUuid,
|
|
||||||
long targetTimestamp,
|
|
||||||
@Deprecated long targetSentTimestamp
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
|
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
|
||||||
@ -20,13 +16,9 @@ public record JsonAdminDelete(
|
|||||||
final var targetAuthor = address.getLegacyIdentifier();
|
final var targetAuthor = address.getLegacyIdentifier();
|
||||||
final var targetAuthorNumber = address.number().orElse(null);
|
final var targetAuthorNumber = address.number().orElse(null);
|
||||||
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
|
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
|
||||||
final var targetTimestamp = adminDelete.targetSentTimestamp();
|
final var targetSentTimestamp = adminDelete.targetSentTimestamp();
|
||||||
|
|
||||||
return new JsonAdminDelete(targetAuthor,
|
return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
|
||||||
targetAuthorNumber,
|
|
||||||
targetAuthorUuid,
|
|
||||||
targetTimestamp,
|
|
||||||
targetTimestamp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ record JsonDataMessage(
|
|||||||
.map(JsonPreview::from)
|
.map(JsonPreview::from)
|
||||||
.toList() : null;
|
.toList() : null;
|
||||||
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
|
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
|
||||||
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get(), dataMessage.remoteDeleteId().get())
|
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
|
||||||
: null;
|
: null;
|
||||||
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
|
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
|
||||||
.stream()
|
.stream()
|
||||||
|
|||||||
@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler
|
|||||||
@Override
|
@Override
|
||||||
public void handleMessage(MessageEnvelope envelope, Throwable exception) {
|
public void handleMessage(MessageEnvelope envelope, Throwable exception) {
|
||||||
final var object = new HashMap<String, Object>();
|
final var object = new HashMap<String, Object>();
|
||||||
object.put("account", m.getSelfIdentifier());
|
object.put("account", m.getSelfNumber());
|
||||||
if (exception != null) {
|
if (exception != null) {
|
||||||
object.put("exception", JsonError.from(exception));
|
object.put("exception", JsonError.from(exception));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,4 +3,4 @@ package org.asamk.signal.json;
|
|||||||
import io.micronaut.jsonschema.JsonSchema;
|
import io.micronaut.jsonschema.JsonSchema;
|
||||||
|
|
||||||
@JsonSchema(title = "RemoteDelete")
|
@JsonSchema(title = "RemoteDelete")
|
||||||
record JsonRemoteDelete(long targetTimestamp, @Deprecated long timestamp) {}
|
record JsonRemoteDelete(long timestamp) {}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package org.asamk.signal.jsonrpc;
|
package org.asamk.signal.jsonrpc;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonParseException;
|
import com.fasterxml.jackson.core.JsonParseException;
|
||||||
import com.fasterxml.jackson.core.StreamReadConstraints;
|
|
||||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@ -9,7 +8,6 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
|
|||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import com.fasterxml.jackson.databind.node.ValueNode;
|
import com.fasterxml.jackson.databind.node.ValueNode;
|
||||||
|
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
|
||||||
import org.asamk.signal.util.Util;
|
import org.asamk.signal.util.Util;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@ -26,7 +24,6 @@ import java.util.stream.StreamSupport;
|
|||||||
public class JsonRpcReader {
|
public class JsonRpcReader {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
|
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
|
||||||
private static final int MAX_JSON_STRING_LENGTH = ((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
|
|
||||||
|
|
||||||
private final JsonRpcSender jsonRpcSender;
|
private final JsonRpcSender jsonRpcSender;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@ -37,23 +34,14 @@ public class JsonRpcReader {
|
|||||||
this.jsonRpcSender = jsonRpcSender;
|
this.jsonRpcSender = jsonRpcSender;
|
||||||
this.input = null;
|
this.input = null;
|
||||||
this.lineSupplier = lineSupplier;
|
this.lineSupplier = lineSupplier;
|
||||||
this.objectMapper = createObjectMapper();
|
this.objectMapper = Util.createJsonObjectMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
|
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
|
||||||
this.jsonRpcSender = jsonRpcSender;
|
this.jsonRpcSender = jsonRpcSender;
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.lineSupplier = null;
|
this.lineSupplier = null;
|
||||||
this.objectMapper = createObjectMapper();
|
this.objectMapper = Util.createJsonObjectMapper();
|
||||||
}
|
|
||||||
|
|
||||||
private static ObjectMapper createObjectMapper() {
|
|
||||||
final var objectMapper = Util.createJsonObjectMapper();
|
|
||||||
objectMapper.getFactory()
|
|
||||||
.setStreamReadConstraints(StreamReadConstraints.builder()
|
|
||||||
.maxStringLength(MAX_JSON_STRING_LENGTH)
|
|
||||||
.build());
|
|
||||||
return objectMapper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) {
|
public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.node.ContainerNode;
|
import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
import org.asamk.signal.AccountIdentifier;
|
|
||||||
import org.asamk.signal.commands.Command;
|
import org.asamk.signal.commands.Command;
|
||||||
import org.asamk.signal.commands.JsonRpcMultiCommand;
|
import org.asamk.signal.commands.JsonRpcMultiCommand;
|
||||||
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
|
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
|
||||||
@ -127,7 +126,7 @@ public class SignalJsonRpcCommandHandler {
|
|||||||
|
|
||||||
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
|
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
|
||||||
if (params != null && params.hasNonNull("account")) {
|
if (params != null && params.hasNonNull("account")) {
|
||||||
final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText()));
|
final var manager = c.getManager(params.get("account").asText());
|
||||||
((ObjectNode) params).remove("account");
|
((ObjectNode) params).remove("account");
|
||||||
if (manager == null) {
|
if (manager == null) {
|
||||||
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
|
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
|
||||||
@ -141,7 +140,7 @@ public class SignalJsonRpcCommandHandler {
|
|||||||
|
|
||||||
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
|
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
|
||||||
if (params != null && params.has("account")) {
|
if (params != null && params.has("account")) {
|
||||||
final var account = AccountIdentifier.normalize(params.get("account").asText());
|
final var account = params.get("account").asText();
|
||||||
((ObjectNode) params).remove("account");
|
((ObjectNode) params).remove("account");
|
||||||
try {
|
try {
|
||||||
return new Pair<>(account, c.getNewRegistrationManager(account));
|
return new Pair<>(account, c.getNewRegistrationManager(account));
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -62,31 +61,17 @@ public class SignalJsonRpcDispatcherHandler {
|
|||||||
public void handleConnection(final MultiAccountManager c) {
|
public void handleConnection(final MultiAccountManager c) {
|
||||||
this.commandHandler = new SignalJsonRpcCommandHandler(c, this::getCommand);
|
this.commandHandler = new SignalJsonRpcCommandHandler(c, this::getCommand);
|
||||||
|
|
||||||
final Consumer<Manager> onManagerAddedReceiveHandler = m -> subscribeReceive(m, true);
|
|
||||||
final Consumer<Manager> onManagerRemovedReceiveHandler = this::unsubscribeReceive;
|
|
||||||
final Consumer<Manager> onManagerAddedReceiveSubscriptionsHandler = m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
|
||||||
createReceiveHandler(m, subscriptionId, false)));
|
|
||||||
final Consumer<Manager> onManagerAddedCallEventSubscriptionsHandler = m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
|
||||||
createCallEventHandler(m, subscriptionId)));
|
|
||||||
|
|
||||||
if (!noReceiveOnStart) {
|
if (!noReceiveOnStart) {
|
||||||
this.subscribeReceive(c.getManagers(), true);
|
this.subscribeReceive(c.getManagers(), true);
|
||||||
c.addOnManagerAddedHandler(onManagerAddedReceiveHandler);
|
c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
|
||||||
c.addOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
|
c.addOnManagerRemovedHandler(this::unsubscribeReceive);
|
||||||
}
|
}
|
||||||
c.addOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
|
c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
||||||
c.addOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
|
createReceiveHandler(m, subscriptionId, false))));
|
||||||
|
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
||||||
|
createCallEventHandler(m, subscriptionId))));
|
||||||
|
|
||||||
try {
|
|
||||||
handleConnection();
|
handleConnection();
|
||||||
} finally {
|
|
||||||
if (!noReceiveOnStart) {
|
|
||||||
c.removeOnManagerAddedHandler(onManagerAddedReceiveHandler);
|
|
||||||
c.removeOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
|
|
||||||
}
|
|
||||||
c.removeOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
|
|
||||||
c.removeOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleConnection(final Manager m) {
|
public void handleConnection(final Manager m) {
|
||||||
|
|||||||
@ -1415,9 +1415,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "java.math.BigDecimal"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "java.math.BigInteger"
|
"type": "java.math.BigInteger"
|
||||||
},
|
},
|
||||||
@ -1774,9 +1771,6 @@
|
|||||||
{
|
{
|
||||||
"type": "kotlin.Any"
|
"type": "kotlin.Any"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "kotlin.Array"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "kotlin.Boolean"
|
"type": "kotlin.Boolean"
|
||||||
},
|
},
|
||||||
@ -1952,7 +1946,42 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter",
|
"type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter",
|
||||||
"allPublicFields": true
|
"allPublicFields": true,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "ALL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CALLABLES"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLASSIFIERS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Companion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FUNCTIONS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NON_SINGLETON_CLASSIFIERS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PACKAGES"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SINGLETON_CLASSIFIERS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TYPE_ALIASES"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VALUES"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VARIABLES"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "kotlinx.coroutines.AwaitAll"
|
"type": "kotlinx.coroutines.AwaitAll"
|
||||||
@ -2454,10 +2483,6 @@
|
|||||||
"type": "org.asamk.signal.commands.FinishLinkCommand$JsonFinishLink",
|
"type": "org.asamk.signal.commands.FinishLinkCommand$JsonFinishLink",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
|
||||||
"name": "aci",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "number",
|
"name": "number",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
@ -2488,10 +2513,6 @@
|
|||||||
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
|
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
|
||||||
"name": "aci",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "number",
|
"name": "number",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
@ -2696,28 +2717,21 @@
|
|||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
"parameterTypes": [
|
"parameterTypes": [
|
||||||
"java.lang.Boolean",
|
"java.lang.Boolean",
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.Boolean",
|
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.String"
|
"java.lang.String"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "<init>",
|
||||||
|
"parameterTypes": [
|
||||||
|
"java.lang.Boolean",
|
||||||
|
"java.lang.String",
|
||||||
|
"java.lang.Boolean"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "captcha",
|
"name": "captcha",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "recoveryKey",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "reregister",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "totp",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "voice",
|
"name": "voice",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
@ -3225,9 +3239,6 @@
|
|||||||
"allDeclaredMethods": true,
|
"allDeclaredMethods": true,
|
||||||
"allDeclaredConstructors": true
|
"allDeclaredConstructors": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.asamk.signal.json.JsonGroupCallUpdate"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.asamk.signal.json.JsonGroupInfo",
|
"type": "org.asamk.signal.json.JsonGroupInfo",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
@ -5104,15 +5115,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.bouncycastle.jcajce.provider.asymmetric.XMSS$Mappings",
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519",
|
"type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519",
|
||||||
"methods": [
|
"methods": [
|
||||||
@ -6188,22 +6190,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.internal.LinkedDeviceInternal_ReturnConverter",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "fromNative",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.Object",
|
|
||||||
"java.lang.Object",
|
|
||||||
"java.lang.Object",
|
|
||||||
"java.lang.Object",
|
|
||||||
"java.lang.Object"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
|
"type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6248,18 +6234,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.ChallengeOption",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "CAPTCHA"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PUSH_CHALLENGE"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.ChatConnection$ListenerBridge",
|
"type": "org.signal.libsignal.net.ChatConnection$ListenerBridge",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6383,46 +6357,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.DeviceIdNotFoundException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.MismatchedDeviceException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String",
|
|
||||||
"org.signal.libsignal.net.MismatchedDeviceException$Entry[]"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.MismatchedDeviceException$Entry",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"byte[]",
|
|
||||||
"int[]",
|
|
||||||
"int[]",
|
|
||||||
"int[]"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.NetworkException",
|
"type": "org.signal.libsignal.net.NetworkException",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6435,33 +6369,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.RateLimitChallengeException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.String",
|
|
||||||
"org.signal.libsignal.net.ChallengeOption[]",
|
|
||||||
"long"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.RequestUnauthorizedException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.RetryLaterException",
|
"type": "org.signal.libsignal.net.RetryLaterException",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6474,42 +6381,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.ServerSideErrorException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.ServiceIdNotFoundException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.TimeoutException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.TransportFailureException",
|
"type": "org.signal.libsignal.net.TransportFailureException",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6522,18 +6393,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.UnexpectedResponseException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.UploadForm",
|
"type": "org.signal.libsignal.net.UploadForm",
|
||||||
"jniAccessible": true,
|
"jniAccessible": true,
|
||||||
@ -6549,30 +6408,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.UploadTooLargeException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.net.UsernameNotAvailableException",
|
|
||||||
"jniAccessible": true,
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.net.internal.BridgeChatListener",
|
"type": "org.signal.libsignal.net.internal.BridgeChatListener",
|
||||||
"jniAccessible": true
|
"jniAccessible": true
|
||||||
@ -6657,13 +6492,6 @@
|
|||||||
"parameterTypes": [
|
"parameterTypes": [
|
||||||
"java.lang.String"
|
"java.lang.String"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"org.signal.libsignal.protocol.SignalProtocolAddress",
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -7191,9 +7019,6 @@
|
|||||||
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential",
|
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential",
|
||||||
"allDeclaredFields": true
|
"allDeclaredFields": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
|
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
|
||||||
"allDeclaredFields": true
|
"allDeclaredFields": true
|
||||||
@ -7260,15 +7085,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.signal.network.util.JsonUtil$UuidDeserializer",
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.signal.storageservice.storage.protos.groups.AccessControl",
|
"type": "org.signal.storageservice.storage.protos.groups.AccessControl",
|
||||||
"fields": [
|
"fields": [
|
||||||
@ -8340,13 +8156,6 @@
|
|||||||
{
|
{
|
||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"int",
|
|
||||||
"int"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -8442,17 +8251,6 @@
|
|||||||
{
|
{
|
||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.String",
|
|
||||||
"java.util.List",
|
|
||||||
"java.util.List",
|
|
||||||
"java.lang.Long"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -8500,12 +8298,6 @@
|
|||||||
"allDeclaredMethods": true,
|
"allDeclaredMethods": true,
|
||||||
"allDeclaredConstructors": true
|
"allDeclaredConstructors": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$Companion"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$RequestType"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite",
|
"type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
@ -8514,13 +8306,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage",
|
"type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "getBody",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.api.push.ServiceId",
|
"type": "org.whispersystems.signalservice.api.push.ServiceId",
|
||||||
@ -8968,9 +8754,6 @@
|
|||||||
"allDeclaredMethods": true,
|
"allDeclaredMethods": true,
|
||||||
"allDeclaredConstructors": true
|
"allDeclaredConstructors": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64",
|
"type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64",
|
||||||
"methods": [
|
"methods": [
|
||||||
@ -8980,15 +8763,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding",
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse",
|
"type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
@ -8996,13 +8770,6 @@
|
|||||||
{
|
{
|
||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.lang.String",
|
|
||||||
"java.lang.String"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -9014,21 +8781,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
|
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "getEncryptedUsername",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "getUsernameHash",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "getZkProof",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse",
|
"type": "org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse",
|
||||||
@ -9178,14 +8931,6 @@
|
|||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"long",
|
|
||||||
"org.signal.libsignal.protocol.kem.KEMPublicKey",
|
|
||||||
"byte[]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "getKeyId",
|
"name": "getKeyId",
|
||||||
"parameterTypes": []
|
"parameterTypes": []
|
||||||
@ -9272,9 +9017,6 @@
|
|||||||
"allDeclaredMethods": true,
|
"allDeclaredMethods": true,
|
||||||
"allDeclaredConstructors": true
|
"allDeclaredConstructors": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts",
|
"type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
@ -9669,67 +9411,6 @@
|
|||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.push.StaleDevices",
|
"type": "org.whispersystems.signalservice.internal.push.StaleDevices",
|
||||||
"allDeclaredFields": true,
|
"allDeclaredFields": true,
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": [
|
|
||||||
"java.util.List"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributes",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "acl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "algorithm"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "credential"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "policy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "signature"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"methods": [
|
|
||||||
{
|
|
||||||
"name": "<init>",
|
|
||||||
"parameterTypes": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributesResponse",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "manifest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "packId"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stickers"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
{
|
||||||
"name": "<init>",
|
"name": "<init>",
|
||||||
@ -10178,29 +9859,6 @@
|
|||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.GroupV2Record$StorySendMode"
|
"type": "org.whispersystems.signalservice.internal.storage.protos.GroupV2Record$StorySendMode"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "deletedAtTimestamp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "packId"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "packKey"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "position"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord$Builder"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.InstalledStickerPackRecord$Companion"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.ManifestRecord",
|
"type": "org.whispersystems.signalservice.internal.storage.protos.ManifestRecord",
|
||||||
"fields": [
|
"fields": [
|
||||||
@ -10241,6 +9899,29 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "deletedAtTimestamp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "packId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "packKey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "position"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Builder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord$Companion"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "org.whispersystems.signalservice.internal.storage.protos.StorageItem",
|
"type": "org.whispersystems.signalservice.internal.storage.protos.StorageItem",
|
||||||
"fields": [
|
"fields": [
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
package org.asamk.signal;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
|
|
||||||
class AccountIdentifierTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void normalizesSignalAndroidAccountKeys() {
|
|
||||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
|
||||||
AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
|
|
||||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
|
||||||
AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void leavesNonAccountIdentifiersUnchanged() {
|
|
||||||
assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
|
|
||||||
assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
|
|
||||||
assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
package org.asamk.signal.commands;
|
|
||||||
|
|
||||||
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
|
||||||
import org.asamk.signal.output.JsonWriter;
|
|
||||||
import org.asamk.signal.testutil.ManagerMock;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.MethodSource;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.lang.reflect.Proxy;
|
|
||||||
import java.net.SocketException;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class SendCommandErrorTest {
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource("failures")
|
|
||||||
void uploadAndTransmissionFailuresKeepTheirClassification(final Exception cause) {
|
|
||||||
final var error = send(cause);
|
|
||||||
|
|
||||||
assertEquals("Failed to send message: " + cause.getMessage() + " (" + cause.getClass().getSimpleName() + ")",
|
|
||||||
error.getMessage());
|
|
||||||
assertSame(cause, error.getCause());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Stream<Exception> failures() {
|
|
||||||
return Stream.of(new AttachmentInvalidException("inline attachment #2: Connection reset"),
|
|
||||||
new SocketException("Connection reset"),
|
|
||||||
new IOException("Server error: 500"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UnexpectedErrorException send(final Exception failure) {
|
|
||||||
final var delegate = ManagerMock.create("+15551234567");
|
|
||||||
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
|
||||||
new Class<?>[]{Manager.class}, (proxy, method, args) -> {
|
|
||||||
if (method.getName().equals("sendMessage")) {
|
|
||||||
throw failure;
|
|
||||||
}
|
|
||||||
return method.invoke(delegate, args);
|
|
||||||
});
|
|
||||||
// Upload and transmission failures must map to the same exception type, so the exit code and the
|
|
||||||
// JSON-RPC error code stay the same.
|
|
||||||
return assertThrows(UnexpectedErrorException.class,
|
|
||||||
() -> new SendCommand().handleCommand(new JsonRpcNamespace(Map.of("noteToSelf",
|
|
||||||
true,
|
|
||||||
"message",
|
|
||||||
"hi",
|
|
||||||
"attachments",
|
|
||||||
List.of("photo.jpg"))), manager, (JsonWriter) ignored -> {}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
package org.asamk.signal.commands;
|
|
||||||
|
|
||||||
import net.sourceforge.argparse4j.ArgumentParsers;
|
|
||||||
import net.sourceforge.argparse4j.DefaultSettings;
|
|
||||||
import net.sourceforge.argparse4j.inf.Namespace;
|
|
||||||
|
|
||||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.asamk.signal.manager.api.Message;
|
|
||||||
import org.asamk.signal.manager.api.SendMessageResults;
|
|
||||||
import org.asamk.signal.output.JsonWriter;
|
|
||||||
import org.asamk.signal.testutil.ManagerMock;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.lang.reflect.Proxy;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class SendCommandParsingTest {
|
|
||||||
|
|
||||||
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
|
|
||||||
private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
|
|
||||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
|
||||||
.includeArgumentNamesAsKeysInResult(true)
|
|
||||||
.build();
|
|
||||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
|
||||||
final var message = send(parser.parseArgs(new String[]{
|
|
||||||
"send",
|
|
||||||
"--note-to-self",
|
|
||||||
"-a",
|
|
||||||
"photo.jpg",
|
|
||||||
"portrait.mp4",
|
|
||||||
"other.mp4",
|
|
||||||
"--attachment-dimensions",
|
|
||||||
"",
|
|
||||||
"1080x1920"
|
|
||||||
}));
|
|
||||||
|
|
||||||
assertEquals(ATTACHMENTS, message.attachments());
|
|
||||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
|
|
||||||
final var message = send(namespace(List.of("", "1920x1080")));
|
|
||||||
assertEquals(ATTACHMENTS, message.attachments());
|
|
||||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cliPreservesBlurHashPositions() throws Exception {
|
|
||||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
|
||||||
.includeArgumentNamesAsKeysInResult(true)
|
|
||||||
.build();
|
|
||||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
|
||||||
final var message = send(parser.parseArgs(new String[]{
|
|
||||||
"send",
|
|
||||||
"--note-to-self",
|
|
||||||
"-a",
|
|
||||||
"photo.jpg",
|
|
||||||
"portrait.mp4",
|
|
||||||
"other.mp4",
|
|
||||||
"--attachment-blurhash",
|
|
||||||
"",
|
|
||||||
BLUR_HASH
|
|
||||||
}));
|
|
||||||
|
|
||||||
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void jsonRpcPassesBlurHashesWithDimensions() throws Exception {
|
|
||||||
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf",
|
|
||||||
true,
|
|
||||||
"attachments",
|
|
||||||
ATTACHMENTS,
|
|
||||||
"attachmentDimensions",
|
|
||||||
List.of("", "1920x1080"),
|
|
||||||
"attachmentBlurhash",
|
|
||||||
List.of("", BLUR_HASH))));
|
|
||||||
|
|
||||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
|
|
||||||
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void omittedDimensionsKeepExistingBehavior() throws Exception {
|
|
||||||
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS)));
|
|
||||||
assertEquals(List.of(), message.attachmentDimensions());
|
|
||||||
assertEquals(List.of(), message.attachmentBlurHashes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsMalformedDimensionsBeforeSending() {
|
|
||||||
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) {
|
|
||||||
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsAttachmentDimensionsOutsideIntegerRange() {
|
|
||||||
for (final var value : List.of("2147483648x1", "1x2147483648")) {
|
|
||||||
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptsMaximumPositiveAttachmentDimensions() throws Exception {
|
|
||||||
final var message = send(namespace(List.of("2147483647x2147483647")));
|
|
||||||
assertEquals(List.of(new Message.AttachmentDimensions(Integer.MAX_VALUE, Integer.MAX_VALUE)),
|
|
||||||
message.attachmentDimensions());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsMalformedBlurHashesBeforeSending() {
|
|
||||||
// Wrong length for its size digit, an unknown size digit, too short.
|
|
||||||
for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) {
|
|
||||||
assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonRpcNamespace blurHashNamespace(List<String> blurHashes) {
|
|
||||||
return new JsonRpcNamespace(Map.of("noteToSelf",
|
|
||||||
true,
|
|
||||||
"attachments",
|
|
||||||
ATTACHMENTS,
|
|
||||||
"attachmentBlurhash",
|
|
||||||
blurHashes));
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonRpcNamespace namespace(List<String> dimensions) {
|
|
||||||
return new JsonRpcNamespace(Map.of("noteToSelf",
|
|
||||||
true,
|
|
||||||
"attachments",
|
|
||||||
ATTACHMENTS,
|
|
||||||
"attachmentDimensions",
|
|
||||||
dimensions));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Message send(Namespace namespace) throws Exception {
|
|
||||||
final var captured = new AtomicReference<Message>();
|
|
||||||
final var delegate = ManagerMock.create("+15551234567");
|
|
||||||
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
|
||||||
new Class<?>[]{Manager.class},
|
|
||||||
(proxy, method, args) -> {
|
|
||||||
if (method.getName().equals("sendMessage")) {
|
|
||||||
captured.set((Message) args[0]);
|
|
||||||
return new SendMessageResults(1, Map.of());
|
|
||||||
}
|
|
||||||
return method.invoke(delegate, args);
|
|
||||||
});
|
|
||||||
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {
|
|
||||||
});
|
|
||||||
return captured.get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,415 +0,0 @@
|
|||||||
package org.asamk.signal.dbus;
|
|
||||||
|
|
||||||
import org.asamk.Signal;
|
|
||||||
import org.asamk.SignalControl;
|
|
||||||
import org.asamk.signal.DbusConfig;
|
|
||||||
import org.asamk.signal.commands.CommandHandler;
|
|
||||||
import org.asamk.signal.commands.ListGroupsCommand;
|
|
||||||
import org.asamk.signal.commands.LocalCommand;
|
|
||||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.asamk.signal.manager.api.Group;
|
|
||||||
import org.asamk.signal.manager.api.GroupId;
|
|
||||||
import org.asamk.signal.manager.api.GroupMember;
|
|
||||||
import org.asamk.signal.manager.api.GroupPermission;
|
|
||||||
import org.asamk.signal.manager.api.Identity;
|
|
||||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
|
||||||
import org.asamk.signal.manager.api.Recipient;
|
|
||||||
import org.asamk.signal.manager.api.RecipientAddress;
|
|
||||||
import org.asamk.signal.manager.api.RecipientIdentifier;
|
|
||||||
import org.asamk.signal.manager.api.SendMessageResults;
|
|
||||||
import org.asamk.signal.manager.api.TrustLevel;
|
|
||||||
import org.asamk.signal.manager.api.TypingAction;
|
|
||||||
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
|
|
||||||
import org.freedesktop.dbus.DBusPath;
|
|
||||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
|
||||||
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
|
|
||||||
import org.freedesktop.dbus.exceptions.DBusExecutionException;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.Timeout;
|
|
||||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.NullSource;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
|
|
||||||
import java.lang.reflect.InvocationHandler;
|
|
||||||
import java.lang.reflect.Proxy;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
class NumberlessDbusTest {
|
|
||||||
|
|
||||||
private static final String NUMBER = "+12025550123";
|
|
||||||
private static final String ACI = "11111111-1111-4111-8111-111111111111";
|
|
||||||
private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222";
|
|
||||||
private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333";
|
|
||||||
private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111";
|
|
||||||
private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() {
|
|
||||||
final var numberless = new TestAccount(null, ACI);
|
|
||||||
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
|
|
||||||
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null);
|
|
||||||
final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath());
|
|
||||||
|
|
||||||
assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)),
|
|
||||||
Set.copyOf(control.listAccounts()));
|
|
||||||
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
|
|
||||||
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
|
|
||||||
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
|
|
||||||
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI));
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@NullSource
|
|
||||||
@ValueSource(strings = NUMBER)
|
|
||||||
void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception {
|
|
||||||
final var account = new TestAccount(number, ACI);
|
|
||||||
final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true);
|
|
||||||
final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
|
|
||||||
|
|
||||||
assertEquals(number == null ? "" : number, signal.getSelfNumber());
|
|
||||||
assertEquals(number, client.getSelfNumber());
|
|
||||||
assertEquals(number == null ? ACI : number, client.getSelfIdentifier());
|
|
||||||
checkSelfOperations(client,
|
|
||||||
account,
|
|
||||||
number == null
|
|
||||||
? new RecipientIdentifier.Uuid(UUID.fromString(ACI))
|
|
||||||
: new RecipientIdentifier.Number(number));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
|
|
||||||
@Nested
|
|
||||||
@Timeout(20)
|
|
||||||
@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+")
|
|
||||||
class SessionBus {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception {
|
|
||||||
final var numberless = new TestAccount(null, ACI);
|
|
||||||
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
|
|
||||||
final var secondNumberless = new TestAccount(null, THIRD_ACI);
|
|
||||||
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager,
|
|
||||||
numbered.manager,
|
|
||||||
secondNumberless.manager), null);
|
|
||||||
final var busname = newBusname();
|
|
||||||
try (final var server = new DbusHandler(false,
|
|
||||||
busname,
|
|
||||||
accounts,
|
|
||||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
|
||||||
server.init();
|
|
||||||
final var control = connection.getRemoteObject(busname,
|
|
||||||
DbusConfig.getObjectPath(),
|
|
||||||
SignalControl.class);
|
|
||||||
assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)),
|
|
||||||
control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet()));
|
|
||||||
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
|
|
||||||
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
|
|
||||||
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
|
|
||||||
assertEquals(ACI, selectAccount(ACI, connection, busname));
|
|
||||||
assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname));
|
|
||||||
assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname));
|
|
||||||
assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname));
|
|
||||||
|
|
||||||
final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
|
|
||||||
assertEquals(3, client.getManagers().size());
|
|
||||||
assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
|
|
||||||
assertNull(client.getManager(ACI).getSelfNumber());
|
|
||||||
assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());
|
|
||||||
|
|
||||||
final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
|
|
||||||
numberless.manager.close();
|
|
||||||
assertEquals(2, control.listAccounts().size());
|
|
||||||
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI));
|
|
||||||
assertThrows(DBusExecutionException.class, removed::getSelfACI);
|
|
||||||
assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI());
|
|
||||||
assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@NullSource
|
|
||||||
@ValueSource(strings = NUMBER)
|
|
||||||
void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception {
|
|
||||||
final var account = new TestAccount(number, ACI);
|
|
||||||
final var busname = newBusname();
|
|
||||||
try (final var server = new DbusHandler(false,
|
|
||||||
busname,
|
|
||||||
account.manager,
|
|
||||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
|
||||||
server.init();
|
|
||||||
assertEquals(ACI, selectAccount(null, connection, busname));
|
|
||||||
assertEquals(ACI, selectAccount(ACI, connection, busname));
|
|
||||||
if (number != null) {
|
|
||||||
assertEquals(ACI, selectAccount(number, connection, busname));
|
|
||||||
}
|
|
||||||
assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception {
|
|
||||||
final var account = new TestAccount(null, ACI);
|
|
||||||
final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI));
|
|
||||||
final var numberedContact = new RecipientAddress(NUMBER);
|
|
||||||
account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(),
|
|
||||||
Recipient.newBuilder().withAddress(numberedContact).build());
|
|
||||||
account.identities = List.of(new Identity(contact,
|
|
||||||
new byte[]{1},
|
|
||||||
"test",
|
|
||||||
new byte[]{2},
|
|
||||||
TrustLevel.TRUSTED_UNVERIFIED,
|
|
||||||
123));
|
|
||||||
final var groupId = GroupId.unknownVersion(new byte[32]);
|
|
||||||
account.groups = List.of(new Group(groupId,
|
|
||||||
"Test group",
|
|
||||||
"",
|
|
||||||
null,
|
|
||||||
Set.of(new GroupMember(contact, true, null, null),
|
|
||||||
new GroupMember(numberedContact, false, null, null)),
|
|
||||||
Set.of(contact),
|
|
||||||
Set.of(contact),
|
|
||||||
Set.of(contact),
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
GroupPermission.EVERY_MEMBER,
|
|
||||||
GroupPermission.EVERY_MEMBER,
|
|
||||||
GroupPermission.EVERY_MEMBER,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false));
|
|
||||||
final var busname = newBusname();
|
|
||||||
try (final var server = new DbusHandler(false,
|
|
||||||
busname,
|
|
||||||
account.manager,
|
|
||||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
|
||||||
server.init();
|
|
||||||
final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
|
|
||||||
assertEquals(List.of(NUMBER), signal.listNumbers());
|
|
||||||
assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers()));
|
|
||||||
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
|
|
||||||
final var contacts = client.getRecipients(false,
|
|
||||||
Optional.empty(),
|
|
||||||
Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
|
|
||||||
Optional.empty());
|
|
||||||
assertEquals(1, contacts.size());
|
|
||||||
assertEquals(contact, contacts.getFirst().getAddress());
|
|
||||||
assertEquals(contact, client.getIdentities().getFirst().recipient());
|
|
||||||
final var group = client.getGroups().getFirst();
|
|
||||||
assertEquals(Set.of(contact, numberedContact),
|
|
||||||
group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet()));
|
|
||||||
assertEquals(Set.of(contact), group.pendingMembers());
|
|
||||||
assertEquals(Set.of(contact), group.requestingMembers());
|
|
||||||
assertEquals(Set.of(contact), group.bannedMembers());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception {
|
|
||||||
final var account = new TestAccount(null, ACI);
|
|
||||||
final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null);
|
|
||||||
final var busname = newBusname();
|
|
||||||
try (final var server = new DbusHandler(false,
|
|
||||||
busname,
|
|
||||||
accounts,
|
|
||||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
|
||||||
server.init();
|
|
||||||
assertEquals(ACI, selectAccount(null, connection, busname));
|
|
||||||
final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
|
|
||||||
assertEquals("", signal.getSelfNumber());
|
|
||||||
final var groupId = GroupId.unknownVersion(new byte[32]);
|
|
||||||
assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI));
|
|
||||||
assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize()));
|
|
||||||
assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of()));
|
|
||||||
assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
|
|
||||||
account.calls.get(0).arguments().get(1));
|
|
||||||
assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1));
|
|
||||||
assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1));
|
|
||||||
assertThrows(Signal.Error.InvalidNumber.class,
|
|
||||||
() -> signal.sendMessage("Invalid local number", List.of(), "2025550124"));
|
|
||||||
|
|
||||||
account.calls.clear();
|
|
||||||
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
|
|
||||||
checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI)));
|
|
||||||
final var received = new LinkedBlockingQueue<MessageEnvelope>();
|
|
||||||
client.addReceiveHandler((envelope, error) -> received.add(envelope));
|
|
||||||
for (final var group : List.of(Optional.<GroupId>empty(), Optional.of(groupId))) {
|
|
||||||
account.emit(messageEnvelope(group));
|
|
||||||
final var envelope = received.poll(5, TimeUnit.SECONDS);
|
|
||||||
assertNotNull(envelope);
|
|
||||||
assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
|
|
||||||
assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
|
|
||||||
assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
|
|
||||||
assertEquals(Optional.of(ACI),
|
|
||||||
envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
|
|
||||||
assertEquals(group,
|
|
||||||
envelope.data()
|
|
||||||
.orElseThrow()
|
|
||||||
.groupContext()
|
|
||||||
.map(MessageEnvelope.Data.GroupContext::groupId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals(0, account.receivers.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void checkSelfOperations(
|
|
||||||
final Manager client,
|
|
||||||
final TestAccount account,
|
|
||||||
final RecipientIdentifier.Single self
|
|
||||||
) throws Exception {
|
|
||||||
final Set<RecipientIdentifier> recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
|
|
||||||
client.sendTypingMessage(TypingAction.START, recipients);
|
|
||||||
client.sendRemoteDeleteMessage(123, recipients);
|
|
||||||
client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
|
|
||||||
assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
|
|
||||||
account.calls.stream().map(Call::name).toList());
|
|
||||||
assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
|
|
||||||
assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
|
|
||||||
assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String selectAccount(
|
|
||||||
final String account,
|
|
||||||
final DBusConnection connection,
|
|
||||||
final String busname
|
|
||||||
) throws Exception {
|
|
||||||
final var selected = new AtomicReference<String>();
|
|
||||||
final var handler = new CommandHandler(null, null) {
|
|
||||||
@Override
|
|
||||||
public void handleLocalCommand(final LocalCommand command, final Manager manager) {
|
|
||||||
selected.set(manager.getSelfACI());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
|
|
||||||
return selected.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String newBusname() {
|
|
||||||
return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MessageEnvelope messageEnvelope(final Optional<GroupId> groupId) {
|
|
||||||
final var data = new MessageEnvelope.Data(123,
|
|
||||||
groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.of("Incoming"),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
List.of(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
List.of(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
|
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty());
|
|
||||||
return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
|
|
||||||
1,
|
|
||||||
123,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.of(data),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty(),
|
|
||||||
Optional.empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Call(String name, List<Object> arguments) {}
|
|
||||||
|
|
||||||
private static final class TestAccount {
|
|
||||||
|
|
||||||
private final Manager manager;
|
|
||||||
private final List<Call> calls = new CopyOnWriteArrayList<>();
|
|
||||||
private final List<Runnable> closedListeners = new CopyOnWriteArrayList<>();
|
|
||||||
private final List<Manager.ReceiveMessageHandler> receivers = new CopyOnWriteArrayList<>();
|
|
||||||
private List<Recipient> recipients = List.of();
|
|
||||||
private List<Group> groups = List.of();
|
|
||||||
private List<Identity> identities = List.of();
|
|
||||||
|
|
||||||
private TestAccount(final String number, final String aci) {
|
|
||||||
manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
|
||||||
new Class<?>[]{Manager.class},
|
|
||||||
(proxy, method, args) -> {
|
|
||||||
if (method.isDefault()) {
|
|
||||||
return InvocationHandler.invokeDefault(proxy, method, args);
|
|
||||||
}
|
|
||||||
return switch (method.getName()) {
|
|
||||||
case "getSelfNumber" -> number;
|
|
||||||
case "getSelfACI" -> aci;
|
|
||||||
case "getLinkedDevices" -> List.of();
|
|
||||||
case "getGroups" -> groups;
|
|
||||||
case "getGroup" -> groups.getFirst();
|
|
||||||
case "getIdentities" -> identities;
|
|
||||||
case "getRecipients" -> recipients;
|
|
||||||
case "isContactBlocked" -> false;
|
|
||||||
case "getContactOrProfileName" -> "Test contact";
|
|
||||||
case "addAddressChangedListener" -> null;
|
|
||||||
case "addClosedListener" -> {
|
|
||||||
closedListeners.add((Runnable) args[0]);
|
|
||||||
yield null;
|
|
||||||
}
|
|
||||||
case "close" -> {
|
|
||||||
closedListeners.forEach(Runnable::run);
|
|
||||||
closedListeners.clear();
|
|
||||||
yield null;
|
|
||||||
}
|
|
||||||
case "addReceiveHandler" -> {
|
|
||||||
receivers.add((Manager.ReceiveMessageHandler) args[0]);
|
|
||||||
yield null;
|
|
||||||
}
|
|
||||||
case "removeReceiveHandler" -> {
|
|
||||||
receivers.remove(args[0]);
|
|
||||||
yield null;
|
|
||||||
}
|
|
||||||
case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage",
|
|
||||||
"sendMessageReaction" -> {
|
|
||||||
calls.add(new Call(method.getName(), List.of(args)));
|
|
||||||
yield new SendMessageResults(42, Map.of());
|
|
||||||
}
|
|
||||||
case "hashCode" -> System.identityHashCode(proxy);
|
|
||||||
case "equals" -> proxy == args[0];
|
|
||||||
default -> throw new UnsupportedOperationException(method.getName());
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void emit(final MessageEnvelope envelope) {
|
|
||||||
receivers.forEach(receiver -> receiver.handleMessage(envelope, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,7 +10,6 @@ import java.net.HttpURLConnection;
|
|||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@ -45,7 +44,7 @@ class SseInitialFlushTest {
|
|||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
port = freePort();
|
port = freePort();
|
||||||
final Manager manager = ManagerMock.create("+10000000000");
|
final Manager manager = ManagerMock.create("+10000000000");
|
||||||
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), Duration.ofSeconds(15), manager);
|
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), manager);
|
||||||
handler.init();
|
handler.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,201 +0,0 @@
|
|||||||
package org.asamk.signal.http;
|
|
||||||
|
|
||||||
import org.asamk.signal.testutil.ManagerMock;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.EOFException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.net.HttpURLConnection;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests the event ids, the replay of missed events for a client that reconnects with a
|
|
||||||
* Last-Event-ID header, and the configurable keep-alive interval of GET /api/v1/events.
|
|
||||||
*/
|
|
||||||
class SseReplayTest {
|
|
||||||
|
|
||||||
private final ManagerMock.State state = new ManagerMock.State();
|
|
||||||
private HttpServerHandler handler;
|
|
||||||
private int port;
|
|
||||||
|
|
||||||
private void start(final Duration keepAliveInterval) throws Exception {
|
|
||||||
try (var ss = new ServerSocket(0)) {
|
|
||||||
port = ss.getLocalPort();
|
|
||||||
}
|
|
||||||
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port),
|
|
||||||
keepAliveInterval,
|
|
||||||
ManagerMock.create("+10000000000", state));
|
|
||||||
handler.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
if (handler != null) {
|
|
||||||
handler.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void eventsHaveIds() throws Exception {
|
|
||||||
start(Duration.ofSeconds(15));
|
|
||||||
try (var client = connect(null, 2)) {
|
|
||||||
receive("one");
|
|
||||||
|
|
||||||
final var event = client.next();
|
|
||||||
assertNotNull(event.id());
|
|
||||||
assertTrue(event.data().contains("\"one\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void reconnectWithLastEventIdReplaysMissedEvents() throws Exception {
|
|
||||||
start(Duration.ofSeconds(15));
|
|
||||||
final String firstId;
|
|
||||||
try (var client = connect(null, 2)) {
|
|
||||||
receive("one");
|
|
||||||
receive("two");
|
|
||||||
firstId = client.next().id();
|
|
||||||
client.next();
|
|
||||||
}
|
|
||||||
receive("three");
|
|
||||||
|
|
||||||
try (var client = connect(firstId, 3)) {
|
|
||||||
assertTrue(client.next().data().contains("\"two\""));
|
|
||||||
assertTrue(client.next().data().contains("\"three\""));
|
|
||||||
|
|
||||||
receive("four");
|
|
||||||
assertTrue(client.next().data().contains("\"four\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unknownLastEventIdReplaysAllBufferedEvents() throws Exception {
|
|
||||||
start(Duration.ofSeconds(15));
|
|
||||||
try (var client = connect(null, 2)) {
|
|
||||||
receive("one");
|
|
||||||
receive("two");
|
|
||||||
client.next();
|
|
||||||
client.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (var client = connect("0-1", 3)) {
|
|
||||||
assertTrue(client.next().data().contains("\"one\""));
|
|
||||||
assertTrue(client.next().data().contains("\"two\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void connectWithoutLastEventIdReplaysNothing() throws Exception {
|
|
||||||
start(Duration.ofSeconds(15));
|
|
||||||
try (var client = connect(null, 2)) {
|
|
||||||
receive("one");
|
|
||||||
client.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (var client = connect(null, 3)) {
|
|
||||||
receive("two");
|
|
||||||
assertTrue(client.next().data().contains("\"two\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void keepAliveIntervalIsConfigurable() throws Exception {
|
|
||||||
start(Duration.ofSeconds(1));
|
|
||||||
try (var client = connect(null, 2)) {
|
|
||||||
// The read timeout is 5 s, the default interval of 15 s would fail here
|
|
||||||
assertEquals(":", client.nextLine());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passes a message to the registered receive handlers, like the manager's receive thread does.
|
|
||||||
*/
|
|
||||||
private void receive(final String message) {
|
|
||||||
final var exception = new RuntimeException(message);
|
|
||||||
for (final var receiveHandler : state.receiveHandlers) {
|
|
||||||
receiveHandler.handleMessage(null, exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects and waits until the server has registered the client, i.e. until the manager has
|
|
||||||
* seen the given total number of addReceiveHandler calls.
|
|
||||||
*/
|
|
||||||
private SseClient connect(final String lastEventId, final int addReceiveHandlerCount) throws Exception {
|
|
||||||
final var client = new SseClient(port, lastEventId);
|
|
||||||
final var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
|
|
||||||
while (state.addReceiveHandlerCount.get() < addReceiveHandlerCount) {
|
|
||||||
if (System.nanoTime() > deadline) {
|
|
||||||
client.close();
|
|
||||||
throw new AssertionError("Client was not registered in time");
|
|
||||||
}
|
|
||||||
Thread.sleep(10);
|
|
||||||
}
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Event(String id, String data) {}
|
|
||||||
|
|
||||||
private static final class SseClient implements AutoCloseable {
|
|
||||||
|
|
||||||
private final HttpURLConnection connection;
|
|
||||||
private final BufferedReader reader;
|
|
||||||
|
|
||||||
SseClient(final int port, final String lastEventId) throws IOException {
|
|
||||||
final var url = URI.create("http://127.0.0.1:" + port + "/api/v1/events").toURL();
|
|
||||||
connection = (HttpURLConnection) url.openConnection();
|
|
||||||
connection.setRequestProperty("Accept", "text/event-stream");
|
|
||||||
if (lastEventId != null) {
|
|
||||||
connection.setRequestProperty("Last-Event-ID", lastEventId);
|
|
||||||
}
|
|
||||||
connection.setConnectTimeout(2_000);
|
|
||||||
connection.setReadTimeout(5_000);
|
|
||||||
assertEquals(200, connection.getResponseCode());
|
|
||||||
reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
String nextLine() throws IOException {
|
|
||||||
final var line = reader.readLine();
|
|
||||||
if (line == null) {
|
|
||||||
throw new EOFException();
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the next event, skipping keep-alive comments.
|
|
||||||
*/
|
|
||||||
Event next() throws IOException {
|
|
||||||
String id = null;
|
|
||||||
String data = null;
|
|
||||||
while (true) {
|
|
||||||
final var line = nextLine();
|
|
||||||
if (line.isEmpty()) {
|
|
||||||
if (data != null) {
|
|
||||||
return new Event(id, data);
|
|
||||||
}
|
|
||||||
} else if (line.startsWith("id:")) {
|
|
||||||
id = line.substring("id:".length());
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
data = line.substring("data:".length());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
connection.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package org.asamk.signal.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import org.asamk.signal.commands.ListAccountsCommand;
|
|
||||||
import org.asamk.signal.manager.Manager;
|
|
||||||
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.lang.reflect.InvocationHandler;
|
|
||||||
import java.lang.reflect.Proxy;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
|
|
||||||
class NumberlessAccountOutputTest {
|
|
||||||
|
|
||||||
private static final String ACI = "11111111-1111-4111-8111-111111111111";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void receiveEventsIdentifyNumberlessAccountByAci() {
|
|
||||||
final var output = new AtomicReference<Object>();
|
|
||||||
new JsonReceiveMessageHandler(manager(null), output::set).handleMessage(null, null);
|
|
||||||
assertEquals(ACI, ((Map<?, ?>) output.get()).get("account"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void receiveEventsKeepNumberedAccountIdentifiers() {
|
|
||||||
final var output = new AtomicReference<Object>();
|
|
||||||
new JsonReceiveMessageHandler(manager("+12025550123"), output::set).handleMessage(null, null);
|
|
||||||
assertEquals("+12025550123", ((Map<?, ?>) output.get()).get("account"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listAccountsIncludesAciWithoutPuttingItInTheNumberField() throws Exception {
|
|
||||||
final var numberless = manager(null);
|
|
||||||
final var numbered = manager("+12025550123");
|
|
||||||
final var multi = new MultiAccountManagerImpl(List.of(numberless, numbered), null);
|
|
||||||
final var output = new AtomicReference<Object>();
|
|
||||||
new ListAccountsCommand().handleCommand(Map.of(), multi, output::set);
|
|
||||||
final var accounts = new ObjectMapper().valueToTree(output.get());
|
|
||||||
assertEquals(2, accounts.size());
|
|
||||||
for (final var account : accounts) {
|
|
||||||
assertEquals(ACI, account.path("aci").asText());
|
|
||||||
assertFalse(ACI.equals(account.path("number").asText()));
|
|
||||||
}
|
|
||||||
assertSame(numbered, multi.getManager("+12025550123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Manager manager(final String number) {
|
|
||||||
return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
|
||||||
new Class<?>[]{Manager.class},
|
|
||||||
(proxy, method, args) -> {
|
|
||||||
if (method.isDefault()) {
|
|
||||||
return InvocationHandler.invokeDefault(proxy, method, args);
|
|
||||||
}
|
|
||||||
return switch (method.getName()) {
|
|
||||||
case "getSelfNumber" -> number;
|
|
||||||
case "getSelfACI" -> ACI;
|
|
||||||
case "hashCode" -> System.identityHashCode(proxy);
|
|
||||||
case "equals" -> proxy == args[0];
|
|
||||||
case "addClosedListener" -> null;
|
|
||||||
default -> throw new UnsupportedOperationException(method.getName());
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -67,13 +67,16 @@ class SubscribeCallEventsTest {
|
|||||||
|
|
||||||
final List<Manager> managers;
|
final List<Manager> managers;
|
||||||
final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
|
final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
|
||||||
final List<Consumer<Manager>> removedHandlers = new ArrayList<>();
|
|
||||||
int addedHandlerRegistrationCount;
|
|
||||||
|
|
||||||
StubMultiAccountManager(List<Manager> managers) {
|
StubMultiAccountManager(List<Manager> managers) {
|
||||||
this.managers = new ArrayList<>(managers);
|
this.managers = new ArrayList<>(managers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getAccountNumbers() {
|
||||||
|
return managers.stream().map(Manager::getSelfNumber).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Manager> getManagers() {
|
public List<Manager> getManagers() {
|
||||||
return managers;
|
return managers;
|
||||||
@ -81,23 +84,11 @@ class SubscribeCallEventsTest {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
|
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
|
||||||
addedHandlerRegistrationCount++;
|
|
||||||
addedHandlers.add(handler);
|
addedHandlers.add(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeOnManagerAddedHandler(Consumer<Manager> handler) {
|
|
||||||
addedHandlers.remove(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
|
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
|
||||||
removedHandlers.add(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void removeOnManagerRemovedHandler(Consumer<Manager> handler) {
|
|
||||||
removedHandlers.remove(handler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -237,9 +228,8 @@ class SubscribeCallEventsTest {
|
|||||||
|
|
||||||
assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener");
|
assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener");
|
||||||
assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener");
|
assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener");
|
||||||
assertEquals(2, multi.addedHandlerRegistrationCount, "should register onManagerAdded handlers");
|
// Also registers an onManagerAdded handler for receive and one for call events
|
||||||
assertEquals(0, multi.addedHandlers.size(), "should remove onManagerAdded handlers after connection closes");
|
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
|
||||||
assertEquals(0, multi.removedHandlers.size(), "should not register onManagerRemoved handlers in manual mode");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -278,17 +268,4 @@ class SubscribeCallEventsTest {
|
|||||||
manager1.state().addCallEventListenerCount.get(),
|
manager1.state().addCallEventListenerCount.get(),
|
||||||
"call events should not be auto-subscribed in multi mode");
|
"call events should not be auto-subscribed in multi mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void multiAccountConnectionRemovesAllManagerHandlersWhenReceiveIsEnabled() {
|
|
||||||
var manager = createManager("+15551111111");
|
|
||||||
var multi = new StubMultiAccountManager(List.of(manager.manager()));
|
|
||||||
var handler = new SignalJsonRpcDispatcherHandler(new CapturingJsonWriter(), () -> null, false);
|
|
||||||
|
|
||||||
handler.handleConnection(multi);
|
|
||||||
|
|
||||||
assertEquals(3, multi.addedHandlerRegistrationCount);
|
|
||||||
assertEquals(0, multi.addedHandlers.size(), "should remove all onManagerAdded handlers after close");
|
|
||||||
assertEquals(0, multi.removedHandlers.size(), "should remove onManagerRemoved handler after close");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
public final class ManagerMock {
|
public final class ManagerMock {
|
||||||
@ -25,8 +24,7 @@ public final class ManagerMock {
|
|||||||
public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0);
|
public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0);
|
||||||
public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0);
|
public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0);
|
||||||
|
|
||||||
// Written by server threads while tests read it
|
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new ArrayList<>();
|
||||||
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new CopyOnWriteArrayList<>();
|
|
||||||
public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0);
|
public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0);
|
||||||
public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0);
|
public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user