mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-10-25 08:39:26 +00:00
Merge branch 'AsamK:master' into android-sqlite-classifier-native
This commit is contained in:
commit
6496e3d93b
10
CHANGELOG.md
10
CHANGELOG.md
@ -2,9 +2,19 @@
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@ -5,12 +5,12 @@ plugins {
|
||||
application
|
||||
eclipse
|
||||
`check-lib-versions`
|
||||
id("org.graalvm.buildtools.native") version "1.1.12"
|
||||
id("org.graalvm.buildtools.native") version "1.1.14"
|
||||
}
|
||||
|
||||
allprojects {
|
||||
group = "org.asamk"
|
||||
version = "0.14.9-SNAPSHOT"
|
||||
version = "0.14.10-SNAPSHOT"
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
6
client/Cargo.lock
generated
6
client/Cargo.lock
generated
@ -850,9 +850,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
@ -1014,7 +1014,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-cli-client"
|
||||
version = "0.14.8"
|
||||
version = "0.14.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "signal-cli-client"
|
||||
version = "0.14.8"
|
||||
version = "0.14.9"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@ -45,6 +45,9 @@
|
||||
<content_attribute id="social-chat">intense</content_attribute>
|
||||
</content_rating>
|
||||
<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">
|
||||
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.8</url>
|
||||
</release>
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
[versions]
|
||||
slf4j = "2.0.19"
|
||||
slf4j = "2.0.20"
|
||||
coroutines = "1.11.0"
|
||||
junit = "6.1.3"
|
||||
micronaut-json-schema = "2.2.0"
|
||||
micronaut-core = "5.2.0"
|
||||
signal-service = "2.15.3_unofficial_153"
|
||||
micronaut-json-schema = "2.3.1"
|
||||
micronaut-core = "5.2.14"
|
||||
signal-service = "2.15.3_unofficial_155"
|
||||
|
||||
[libraries]
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85.2"
|
||||
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.2"
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86"
|
||||
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.22.3"
|
||||
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
|
||||
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
|
||||
zxing = "com.google.zxing:core:3.5.4"
|
||||
@ -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" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
|
||||
logback = "ch.qos.logback:logback-classic:1.6.3"
|
||||
logback = "ch.qos.logback:logback-classic:1.6.5"
|
||||
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
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
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
52
gradlew.bat
vendored
52
gradlew.bat
vendored
@ -26,6 +26,33 @@
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
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
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
|
||||
@rem Execute gradlew
|
||||
@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
|
||||
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
|
||||
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
|
||||
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@ -12,8 +12,12 @@ public interface MultiAccountManager extends AutoCloseable {
|
||||
|
||||
void addOnManagerAddedHandler(Consumer<Manager> handler);
|
||||
|
||||
void removeOnManagerAddedHandler(Consumer<Manager> handler);
|
||||
|
||||
void addOnManagerRemovedHandler(Consumer<Manager> handler);
|
||||
|
||||
void removeOnManagerRemovedHandler(Consumer<Manager> handler);
|
||||
|
||||
Manager getManager(String phoneNumber);
|
||||
|
||||
URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException;
|
||||
|
||||
@ -6,7 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
|
||||
import java.io.Closeable;
|
||||
@ -18,7 +18,7 @@ public interface RegistrationManager extends Closeable {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException;
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException;
|
||||
|
||||
void verifyAccount(
|
||||
String verificationCode,
|
||||
@ -29,7 +29,7 @@ public interface RegistrationManager extends Closeable {
|
||||
String recoveryKey,
|
||||
boolean forceRegister,
|
||||
Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException;
|
||||
) throws IOException, RateLimitException, TwoFactorRequired;
|
||||
|
||||
void deleteLocalAccountData() throws IOException;
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@ package org.asamk.signal.manager.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TotpRequiredException extends IOException {
|
||||
public class TwoFactorRequired extends IOException {
|
||||
|
||||
public TotpRequiredException() {
|
||||
public TwoFactorRequired() {
|
||||
super("A TOTP token is required or the supplied token is incorrect");
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@ package org.asamk.signal.manager.config;
|
||||
import org.signal.libsignal.net.Network.Environment;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.network.config.HttpProxy;
|
||||
import org.signal.network.config.SignalCdnUrl;
|
||||
import org.signal.network.config.SignalCdsiUrl;
|
||||
import org.signal.network.config.SignalProxy;
|
||||
@ -44,7 +43,6 @@ class LiveConfig {
|
||||
|
||||
private static final Optional<Dns> dns = 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()
|
||||
.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==");
|
||||
@ -72,7 +70,6 @@ class LiveConfig {
|
||||
interceptors,
|
||||
dns,
|
||||
proxy,
|
||||
systemProxy,
|
||||
zkGroupServerPublicParams,
|
||||
genericServerPublicParams,
|
||||
backupServerPublicParams,
|
||||
|
||||
@ -3,7 +3,6 @@ package org.asamk.signal.manager.config;
|
||||
import org.signal.libsignal.net.Network;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.network.config.HttpProxy;
|
||||
import org.signal.network.config.SignalCdnUrl;
|
||||
import org.signal.network.config.SignalCdsiUrl;
|
||||
import org.signal.network.config.SignalProxy;
|
||||
@ -44,7 +43,6 @@ class StagingConfig {
|
||||
|
||||
private static final Optional<Dns> dns = 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()
|
||||
.decode("ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==");
|
||||
@ -72,7 +70,6 @@ class StagingConfig {
|
||||
interceptors,
|
||||
dns,
|
||||
proxy,
|
||||
systemProxy,
|
||||
zkGroupServerPublicParams,
|
||||
genericServerPublicParams,
|
||||
backupServerPublicParams,
|
||||
|
||||
@ -560,9 +560,9 @@ public class AccountHelper {
|
||||
final List<DeviceInfo> devices = handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
|
||||
.getDevices(cont));
|
||||
final var deviceId = account.getDeviceId();
|
||||
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
|
||||
final var device = devices.stream().filter(d -> d.getId() == deviceId).findFirst();
|
||||
if (device.isPresent()) {
|
||||
account.setEncryptedDeviceName(device.get().name);
|
||||
account.setEncryptedDeviceName(device.get().getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,11 +2,13 @@ package org.asamk.signal.manager.helper;
|
||||
|
||||
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.internal.SignalDependencies;
|
||||
import org.asamk.signal.manager.storage.AttachmentStore;
|
||||
import org.asamk.signal.manager.util.AttachmentUtils;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.MimeUtils;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.slf4j.Logger;
|
||||
@ -21,6 +23,7 @@ import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@ -28,6 +31,8 @@ import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class AttachmentHelper {
|
||||
|
||||
@ -35,12 +40,21 @@ public class AttachmentHelper {
|
||||
|
||||
private final SignalDependencies dependencies;
|
||||
private final AttachmentStore attachmentStore;
|
||||
private final Context context;
|
||||
private final Uploader uploader;
|
||||
private final Supplier<File> dataPath;
|
||||
|
||||
public AttachmentHelper(final Context context) {
|
||||
this.context = context;
|
||||
this.dependencies = context.getDependencies();
|
||||
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) {
|
||||
@ -51,29 +65,36 @@ public class AttachmentHelper {
|
||||
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(
|
||||
final List<String> attachments,
|
||||
final List<AttachmentDimensions> dimensions,
|
||||
final List<String> blurHashes,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
) throws AttachmentInvalidException {
|
||||
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote);
|
||||
|
||||
try {
|
||||
// Upload attachments here, so we only upload once even for multiple recipients
|
||||
final var attachmentPointers = new ArrayList<SignalServiceAttachment>(attachmentStreams.size());
|
||||
for (final var attachmentStream : attachmentStreams) {
|
||||
attachmentPointers.add(uploadAttachment(attachmentStream));
|
||||
for (var i = 0; i < attachmentStreams.size(); i++) {
|
||||
final var attachment = attachments.get(i);
|
||||
try {
|
||||
attachmentPointers.add(uploader.upload(attachmentStreams.get(i)));
|
||||
} catch (IOException e) {
|
||||
throw invalid(describe(attachment, inlineAttachmentLabel(i)), e);
|
||||
}
|
||||
}
|
||||
return attachmentPointers;
|
||||
} finally {
|
||||
for (final var attachmentStream : attachmentStreams) {
|
||||
attachmentStream.close();
|
||||
}
|
||||
closeAll(attachmentStreams);
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
|
||||
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException {
|
||||
return uploadAttachments(attachments, List.of(), List.of(), false);
|
||||
}
|
||||
|
||||
@ -82,45 +103,55 @@ public class AttachmentHelper {
|
||||
List<AttachmentDimensions> dimensions,
|
||||
List<String> blurHashes,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
) throws AttachmentInvalidException {
|
||||
if (attachments == null) {
|
||||
return null;
|
||||
return List.of();
|
||||
}
|
||||
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
|
||||
for (var i = 0; i < attachments.size(); i++) {
|
||||
final var size = i < dimensions.size() ? dimensions.get(i) : null;
|
||||
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null;
|
||||
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, blurHash, voiceNote));
|
||||
signalServiceAttachments.add(getAttachmentStream(attachments.get(i),
|
||||
inlineAttachmentLabel(i),
|
||||
size,
|
||||
blurHash,
|
||||
voiceNote));
|
||||
}
|
||||
return signalServiceAttachments;
|
||||
}
|
||||
|
||||
private SignalServiceAttachmentStream getAttachmentStream(
|
||||
final String attachment,
|
||||
final String inlineLabel,
|
||||
final AttachmentDimensions dimensions,
|
||||
final String blurHash,
|
||||
final boolean voiceNote
|
||||
) throws AttachmentInvalidException {
|
||||
final var label = describe(attachment, inlineLabel);
|
||||
try {
|
||||
// Reject local files that point into the signal-cli data directory
|
||||
if (attachment != null && !attachment.startsWith("data:")) {
|
||||
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())) {
|
||||
throw new AttachmentInvalidException(attachment,
|
||||
new IOException("Attaching files from the signal-cli data directory is not allowed"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
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
|
||||
final var canonical = new File(attachment).getCanonicalFile();
|
||||
final var dataPath = this.dataPath.get().getCanonicalFile();
|
||||
if (canonical.toPath().startsWith(dataPath.toPath())) {
|
||||
throw new IOException("Attaching files from the signal-cli data directory is not allowed");
|
||||
}
|
||||
streamDetailsAndFileName = Utils.createStreamDetails(attachment);
|
||||
}
|
||||
|
||||
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
|
||||
final var streamDetails = streamDetailsAndFileName.first();
|
||||
final var uploadSpec = getResumableUploadSpec(streamDetails);
|
||||
|
||||
return AttachmentUtils.createAttachmentStream(streamDetails,
|
||||
streamDetailsAndFileName.second(),
|
||||
voiceNote,
|
||||
@ -128,7 +159,7 @@ public class AttachmentHelper {
|
||||
blurHash,
|
||||
uploadSpec);
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
throw invalid(label, e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,17 +167,89 @@ public class AttachmentHelper {
|
||||
final var streamLength = streamDetails.getLength();
|
||||
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
|
||||
streamLength));
|
||||
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
|
||||
return uploader.getResumableUploadSpec(ciphertextLength);
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
|
||||
final var attachmentStream = getAttachmentStream(attachment, null, null, false);
|
||||
return uploadAttachment(attachmentStream);
|
||||
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws AttachmentInvalidException {
|
||||
return uploadAttachment(attachment, "inline attachment");
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException {
|
||||
var messageSender = dependencies.getMessageSender();
|
||||
return messageSender.uploadAttachment(attachment);
|
||||
return uploader.upload(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) {
|
||||
@ -223,4 +326,24 @@ public class AttachmentHelper {
|
||||
|
||||
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,10 +458,14 @@ public class CallManager implements AutoCloseable {
|
||||
}
|
||||
|
||||
private String buildConfig(CallState state) {
|
||||
return buildConfig(state, account.getDeviceId());
|
||||
}
|
||||
|
||||
static String buildConfig(CallState state, int localDeviceId) {
|
||||
var config = mapper.createObjectNode();
|
||||
config.put("call_id", Utils.callIdUnsigned(state.callId));
|
||||
config.put("is_outgoing", state.isOutgoing);
|
||||
config.put("local_device_id", 1);
|
||||
config.put("local_device_id", localDeviceId);
|
||||
return writeJson(config);
|
||||
}
|
||||
|
||||
|
||||
@ -480,15 +480,16 @@ class GroupV2Helper {
|
||||
}
|
||||
|
||||
// We need to accept the invite with the ACI or PNI that was used for the invitation
|
||||
final var selfAddress = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId);
|
||||
final var account = context.getAccount();
|
||||
final var selfServiceId = groupInfoV2.getPendingMemberAddresses()
|
||||
.stream()
|
||||
.filter(s -> s.matches(selfAddress))
|
||||
.map(SignalServiceAddress::getServiceId)
|
||||
.filter(s -> s.equals(account.getAci()) || s.equals(account.getPni()))
|
||||
.findFirst();
|
||||
if (selfServiceId.isEmpty()) {
|
||||
throw new IOException("Cannot find service ID for self to accept invite");
|
||||
}
|
||||
final var serviceId = selfServiceId.get().getServiceId();
|
||||
final var serviceId = selfServiceId.get();
|
||||
|
||||
final GroupChange.Actions.Builder change;
|
||||
if (serviceId instanceof ACI) {
|
||||
@ -497,7 +498,8 @@ class GroupV2Helper {
|
||||
change = groupOperations.createAcceptPniInviteChange(profileKeyCredential);
|
||||
}
|
||||
|
||||
change.sourceUserId(serviceId.toByteString());
|
||||
// The change is made by the account's ACI, also when it promotes a PNI invitation
|
||||
change.sourceUserId(account.getAci().toByteString());
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
@ -634,16 +636,18 @@ class GroupV2Helper {
|
||||
final DecryptedGroupChange decryptedChange;
|
||||
final DecryptedGroup decryptedGroupState;
|
||||
|
||||
var signedGroupChange = dependencies.getGroupsV2Api()
|
||||
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
|
||||
|
||||
final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier();
|
||||
try {
|
||||
decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
|
||||
decryptedChange = groupOperations.decryptChange(signedGroupChange.group_change,
|
||||
DecryptChangeVerificationMode.verify(groupId)).orElseThrow();
|
||||
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
var signedGroupChange = dependencies.getGroupsV2Api()
|
||||
.patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
|
||||
|
||||
groupInfoV2.setGroup(decryptedGroupState);
|
||||
|
||||
return new Pair<>(decryptedGroupState, signedGroupChange);
|
||||
|
||||
@ -585,6 +585,21 @@ public final class IncomingMessageHandler {
|
||||
actions.addAll(dataResults.first());
|
||||
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()) {
|
||||
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
|
||||
sender.recipientId(),
|
||||
|
||||
@ -365,7 +365,7 @@ public final class ProfileHelper {
|
||||
}
|
||||
|
||||
if (recipientId.equals(account.getSelfRecipientId())) {
|
||||
final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess();
|
||||
final var isUnrestricted = encryptedProfile.getUnrestrictedUnidentifiedAccess();
|
||||
if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
|
||||
account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
|
||||
}
|
||||
|
||||
@ -42,8 +42,8 @@ public class ReceiveHelper {
|
||||
|
||||
private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false);
|
||||
private boolean hasCaughtUpWithOldMessages = false;
|
||||
private boolean isWaitingForMessage = false;
|
||||
private boolean shouldStop = false;
|
||||
private volatile boolean isWaitingForMessage = false;
|
||||
private volatile boolean shouldStop = false;
|
||||
private Callable authenticationFailureListener;
|
||||
private Callable caughtUpWithOldMessagesListener;
|
||||
|
||||
@ -71,6 +71,15 @@ public class ReceiveHelper {
|
||||
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) {
|
||||
while (!shouldStop) {
|
||||
try {
|
||||
@ -111,7 +120,6 @@ public class ReceiveHelper {
|
||||
signalWebSocket.removeKeepAliveToken("receive");
|
||||
signalWebSocket.disconnect();
|
||||
webSocketStateDisposable.dispose();
|
||||
shouldStop = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -555,7 +555,8 @@ public class SendHelper {
|
||||
recipients,
|
||||
unidentifiedAccess,
|
||||
groupSendEndorsements,
|
||||
message),
|
||||
message,
|
||||
null),
|
||||
recipientIds,
|
||||
groupInfo,
|
||||
false);
|
||||
|
||||
@ -90,7 +90,6 @@ import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
|
||||
import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
|
||||
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.MimeUtils;
|
||||
import org.asamk.signal.manager.util.PhoneNumberFormatter;
|
||||
@ -124,10 +123,8 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
|
||||
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
@ -953,15 +950,8 @@ public class ManagerImpl implements Manager {
|
||||
final var remainder = result.getSecond();
|
||||
if (remainder != null) {
|
||||
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);
|
||||
additionalAttachments.add(context.getAttachmentHelper().uploadAttachment(textAttachment));
|
||||
additionalAttachments.add(context.getAttachmentHelper().uploadLongTextAttachment(messageBytes));
|
||||
} else {
|
||||
messageBuilder.withBody(message.messageText());
|
||||
}
|
||||
@ -994,9 +984,12 @@ public class ManagerImpl implements Manager {
|
||||
final var quote = message.quote().get();
|
||||
final var quotedAttachments = new ArrayList<SignalServiceDataMessage.Quote.QuotedAttachment>();
|
||||
for (final var a : quote.attachments()) {
|
||||
final var label = "quote thumbnail #" + (quotedAttachments.size() + 1);
|
||||
final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(),
|
||||
a.filename(),
|
||||
a.preview() == null ? null : context.getAttachmentHelper().uploadAttachment(a.preview()));
|
||||
a.preview() == null
|
||||
? null
|
||||
: context.getAttachmentHelper().uploadAttachment(a.preview(), label));
|
||||
quotedAttachments.add(quotedAttachment);
|
||||
}
|
||||
messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
|
||||
@ -1027,10 +1020,7 @@ public class ManagerImpl implements Manager {
|
||||
if (streamDetails == null) {
|
||||
throw new InvalidStickerException("Missing local sticker file");
|
||||
}
|
||||
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
|
||||
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
|
||||
Optional.empty(),
|
||||
uploadSpec);
|
||||
final var stickerAttachment = context.getAttachmentHelper().createStickerAttachmentStream(streamDetails);
|
||||
messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
|
||||
stickerPack.packKey(),
|
||||
stickerId,
|
||||
@ -1040,8 +1030,9 @@ public class ManagerImpl implements Manager {
|
||||
if (!message.previews().isEmpty()) {
|
||||
final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
|
||||
for (final var p : message.previews()) {
|
||||
final var label = "link preview image #" + (previews.size() + 1);
|
||||
final var image = p.image().isPresent() ? context.getAttachmentHelper()
|
||||
.uploadAttachment(p.image().get()) : null;
|
||||
.uploadAttachment(p.image().get(), label) : null;
|
||||
previews.add(new SignalServicePreview(p.url(),
|
||||
p.title(),
|
||||
p.description(),
|
||||
@ -1519,15 +1510,18 @@ public class ManagerImpl implements Manager {
|
||||
private static final AtomicInteger threadNumber = new AtomicInteger(0);
|
||||
|
||||
private void startReceiveThreadIfRequired() {
|
||||
if (receiveThread != null || isReceivingSynchronous) {
|
||||
if (isReceiveThreadAlive() || isReceivingSynchronous) {
|
||||
return;
|
||||
}
|
||||
context.getReceiveHelper().clearStopRequest();
|
||||
receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
|
||||
logger.debug("Starting receiving messages");
|
||||
context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
|
||||
logger.debug("Finished receiving messages");
|
||||
synchronized (messageHandlers) {
|
||||
receiveThread = null;
|
||||
if (receiveThread == Thread.currentThread()) {
|
||||
receiveThread = null;
|
||||
}
|
||||
|
||||
// Check if in the meantime another handler has been registered
|
||||
if (!messageHandlers.isEmpty()) {
|
||||
@ -1538,6 +1532,13 @@ 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) {
|
||||
synchronized (messageHandlers) {
|
||||
Stream.concat(messageHandlers.stream(), weakHandlers.stream()).forEach(h -> {
|
||||
@ -1560,7 +1561,6 @@ public class ManagerImpl implements Manager {
|
||||
return;
|
||||
}
|
||||
thread = receiveThread;
|
||||
receiveThread = null;
|
||||
}
|
||||
|
||||
stopReceiveThread(thread);
|
||||
@ -1616,11 +1616,12 @@ public class ManagerImpl implements Manager {
|
||||
ReceiveMessageHandler handler
|
||||
) throws IOException, AlreadyReceivingException {
|
||||
synchronized (messageHandlers) {
|
||||
if (isReceiving()) {
|
||||
if (isReceiving() || isReceiveThreadAlive()) {
|
||||
throw new AlreadyReceivingException("Already receiving message.");
|
||||
}
|
||||
isReceivingSynchronous = true;
|
||||
receiveThread = Thread.currentThread();
|
||||
context.getReceiveHelper().clearStopRequest();
|
||||
}
|
||||
try {
|
||||
context.getReceiveHelper().receiveMessages(timeout, maxMessages, (envelope, e) -> {
|
||||
@ -2045,7 +2046,6 @@ public class ManagerImpl implements Manager {
|
||||
weakHandlers.clear();
|
||||
messageHandlers.clear();
|
||||
thread = receiveThread;
|
||||
receiveThread = null;
|
||||
}
|
||||
if (thread != null) {
|
||||
stopReceiveThread(thread);
|
||||
|
||||
@ -68,6 +68,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
|
||||
synchronized (onManagerAddedHandlers) {
|
||||
onManagerAddedHandlers.remove(handler);
|
||||
}
|
||||
}
|
||||
|
||||
void removeManager(final Manager m) {
|
||||
synchronized (managers) {
|
||||
if (!managers.remove(m)) {
|
||||
@ -88,6 +95,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
||||
synchronized (onManagerRemovedHandlers) {
|
||||
onManagerRemovedHandlers.remove(handler);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manager getManager(final String identifier) {
|
||||
synchronized (managers) {
|
||||
|
||||
@ -116,8 +116,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
public URI getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
try {
|
||||
var url = urlFuture.get(30, TimeUnit.SECONDS);
|
||||
// Mode.Link(false) does not advertise any capabilities itself.
|
||||
return new URI(url + "&capabilities=nopni");
|
||||
return new URI(url);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
throw new TimeoutException("Timed out waiting for provisioning URL");
|
||||
} catch (InterruptedException e) {
|
||||
|
||||
@ -25,7 +25,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
||||
import org.asamk.signal.manager.api.UpdateProfile;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
@ -123,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException {
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TwoFactorRequired, VerificationMethodNotAvailableException {
|
||||
if (account.isRegistered()
|
||||
&& account.getServiceEnvironment() != null
|
||||
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
|
||||
@ -212,7 +212,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
) throws IOException, RateLimitException, TwoFactorRequired {
|
||||
if (account.getAci() == null) {
|
||||
throw new IOException("Recovery-key registration requires an ACI account identifier");
|
||||
}
|
||||
@ -228,13 +228,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
|
||||
private boolean attemptRecoverAccount(
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
) 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 (TotpRequiredException | RateLimitException e) {
|
||||
} catch (TwoFactorRequired | RateLimitException e) {
|
||||
throw e;
|
||||
} catch (RecoveryRequestFailedException e) {
|
||||
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
|
||||
@ -246,7 +246,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
final Integer totp,
|
||||
final boolean includeRegistrationLock,
|
||||
final AccountEntropyPool accountEntropyPool
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
) throws IOException, RateLimitException, TwoFactorRequired {
|
||||
if (account.getPniIdentityKeyPair() == null) {
|
||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
@ -273,6 +273,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
true,
|
||||
account.getAci(),
|
||||
totp,
|
||||
null,
|
||||
cont));
|
||||
} catch (BadRequestException e) {
|
||||
switch (e.getError()) {
|
||||
@ -283,12 +284,12 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
recoverAccount(totp, true, accountEntropyPool);
|
||||
return;
|
||||
}
|
||||
case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException();
|
||||
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);
|
||||
throw new IOException("signal-cli is too old to register this account", e);
|
||||
default -> throw new IOException("Signal rejected recovery-key registration", e);
|
||||
}
|
||||
}
|
||||
@ -416,7 +417,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
|
||||
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
|
||||
|
||||
finishManagerRegistration(response.isStorageCapable());
|
||||
finishManagerRegistration(response.getStorageCapable());
|
||||
}
|
||||
|
||||
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
|
||||
|
||||
@ -11,6 +11,7 @@ import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.network.api.AccountApiV2;
|
||||
import org.signal.network.api.AttachmentApi;
|
||||
import org.signal.network.api.CallingApi;
|
||||
import org.signal.network.api.CdnApi;
|
||||
import org.signal.network.api.CdsApi;
|
||||
import org.signal.network.api.CertificateApi;
|
||||
import org.signal.network.api.LinkDeviceApi;
|
||||
@ -91,6 +92,7 @@ public class SignalDependencies {
|
||||
private ClientZkOperations clientZkOperations;
|
||||
private ProfileService profileService;
|
||||
private ProfileApi profileApi;
|
||||
private CdnApi cdnApi;
|
||||
private CdnService cdnService;
|
||||
private PreKeyRepository preKeyRepository;
|
||||
private SignalRestClient signalRestClient;
|
||||
@ -271,7 +273,7 @@ public class SignalDependencies {
|
||||
|
||||
public AttachmentApi getAttachmentApi() {
|
||||
return getOrCreate(() -> attachmentApi,
|
||||
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
|
||||
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket()));
|
||||
}
|
||||
|
||||
public CallingApi getCallingApi() {
|
||||
@ -353,7 +355,7 @@ public class SignalDependencies {
|
||||
|
||||
public CdnService getCdnService() {
|
||||
return getOrCreate(() -> cdnService,
|
||||
() -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
|
||||
() -> cdnService = new CdnService(getCdnApi(), getAttachmentApi(), () -> null));
|
||||
}
|
||||
|
||||
public PreKeyRepository getPreKeyRepository() {
|
||||
@ -378,7 +380,8 @@ public class SignalDependencies {
|
||||
ServiceConfig.MAX_ENVELOPE_SIZE,
|
||||
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
|
||||
() -> true,
|
||||
getPreKeyRepository()));
|
||||
getPreKeyRepository(),
|
||||
getCdnService()));
|
||||
}
|
||||
|
||||
public List<SecureValueRecovery> getSecureValueRecovery() {
|
||||
@ -397,6 +400,10 @@ public class SignalDependencies {
|
||||
getClientZkProfileOperations()));
|
||||
}
|
||||
|
||||
public CdnApi getCdnApi() {
|
||||
return getOrCreate(() -> cdnApi, () -> cdnApi = new CdnApi(getSignalRestClient()));
|
||||
}
|
||||
|
||||
public ProfileService getProfileService() {
|
||||
return getOrCreate(() -> profileService,
|
||||
() -> profileService = new ProfileService(getClientZkProfileOperations(),
|
||||
|
||||
@ -65,7 +65,7 @@ public class ProfileUtils {
|
||||
final SignalServiceProfile encryptedProfile,
|
||||
final ProfileCipher profileCipher
|
||||
) {
|
||||
if (encryptedProfile.isUnrestrictedUnidentifiedAccess()) {
|
||||
if (encryptedProfile.getUnrestrictedUnidentifiedAccess()) {
|
||||
return Profile.UnidentifiedAccessMode.UNRESTRICTED;
|
||||
}
|
||||
|
||||
@ -81,11 +81,13 @@ public class ProfileUtils {
|
||||
|
||||
public static HashSet<Profile.Capability> getCapabilities(final SignalServiceProfile encryptedProfile) {
|
||||
final var capabilities = new HashSet<Profile.Capability>();
|
||||
if (encryptedProfile.getCapabilities().isStorage()) {
|
||||
capabilities.add(Profile.Capability.storage);
|
||||
}
|
||||
if (encryptedProfile.getCapabilities().isStorageServiceEncryptionV2()) {
|
||||
capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability);
|
||||
if (encryptedProfile.getCapabilities() != null) {
|
||||
if (encryptedProfile.getCapabilities().getStorage()) {
|
||||
capabilities.add(Profile.Capability.storage);
|
||||
}
|
||||
if (encryptedProfile.getCapabilities().getStorageServiceEncryptionV2()) {
|
||||
capabilities.add(Profile.Capability.storageServiceEncryptionV2Capability);
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
|
||||
@ -0,0 +1,289 @@
|
||||
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,16 +1,22 @@
|
||||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
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.util.Utils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
@ -23,6 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
*/
|
||||
class CallManagerTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
// --- Reflection helpers for private static methods ---
|
||||
|
||||
private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES;
|
||||
@ -177,6 +185,38 @@ class CallManagerTest {
|
||||
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
|
||||
//
|
||||
|
||||
@ -122,6 +122,8 @@ class NumberlessProvisioningTest {
|
||||
"signal-cli-test",
|
||||
null,
|
||||
false,
|
||||
config.signalServiceConfiguration(),
|
||||
() -> false,
|
||||
1000L,
|
||||
new SecureRandom(),
|
||||
client);
|
||||
|
||||
@ -0,0 +1,256 @@
|
||||
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.function.BooleanSupplier;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 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)) {
|
||||
stoppingLoops++;
|
||||
lock.notifyAll();
|
||||
await(() -> !holdStoppingLoops);
|
||||
stoppingLoops--;
|
||||
}
|
||||
runningLoops--;
|
||||
lock.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
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 +1 @@
|
||||
0.102.1
|
||||
0.105.0
|
||||
|
||||
@ -50,6 +50,8 @@ 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.
|
||||
They typically contain an additional message with details.
|
||||
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>
|
||||
|
||||
|
||||
@ -38,6 +38,11 @@ With `--http` signal-cli exposes three endpoints;
|
||||
* 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
|
||||
|
||||
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
|
||||
|
||||
In JSON-RPC mode, signal-cli will read requests from stdin.
|
||||
|
||||
@ -1105,6 +1105,10 @@ Expose a JSON-RPC interface as http endpoint (default localhost:8080).
|
||||
The JSON-RPC endpoint is `/api/v1/rpc`. +
|
||||
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*::
|
||||
Export DBus interface on user bus. +
|
||||
See **signal-cli-dbus**(5) for info on the dbus interface.
|
||||
|
||||
@ -285,6 +285,7 @@ done
|
||||
|
||||
run_main -a "$NUMBER_1" --output="$OUTPUT" receive
|
||||
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
|
||||
|
||||
## Unregister
|
||||
|
||||
@ -8,7 +8,7 @@ public class BaseConfig {
|
||||
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
|
||||
|
||||
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
|
||||
.orElse("Signal-Android/8.27.0");
|
||||
.orElse("Signal-Android/8.31.1");
|
||||
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
|
||||
? "signal-cli"
|
||||
: PROJECT_NAME + "/" + PROJECT_VERSION;
|
||||
|
||||
@ -30,6 +30,7 @@ import java.net.InetSocketAddress;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.channels.Channel;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.SequencedCollection;
|
||||
@ -69,6 +70,10 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
||||
.nargs("?")
|
||||
.setConst("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")
|
||||
.help("Don’t print received messages to stdout.")
|
||||
.action(Arguments.storeTrue());
|
||||
@ -188,7 +193,11 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
||||
final var httpAddress = ns.getString("http");
|
||||
if (httpAddress != null) {
|
||||
final var address = IOUtils.parseInetSocketAddress(httpAddress);
|
||||
daemonHandler.runHttp(address);
|
||||
final var keepAliveInterval = ns.getInt("sse-keepalive-interval");
|
||||
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"));
|
||||
@ -235,7 +244,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
||||
|
||||
public abstract void runDbus(boolean isDbusSystem, final String busname) throws CommandException;
|
||||
|
||||
public abstract void runHttp(InetSocketAddress address) throws CommandException;
|
||||
public abstract void runHttp(InetSocketAddress address, Duration keepAliveInterval) throws CommandException;
|
||||
|
||||
protected final void runSocket(final SocketHandler socketHandler) {
|
||||
socketHandler.init();
|
||||
@ -291,8 +300,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runHttp(InetSocketAddress address) throws CommandException {
|
||||
runHttp(new HttpServerHandler(address, m));
|
||||
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException {
|
||||
runHttp(new HttpServerHandler(address, keepAliveInterval, m));
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,8 +325,8 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runHttp(final InetSocketAddress address) throws CommandException {
|
||||
runHttp(new HttpServerHandler(address, c));
|
||||
public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException {
|
||||
runHttp(new HttpServerHandler(address, keepAliveInterval, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.output.JsonWriter;
|
||||
import org.asamk.signal.util.CommandUtil;
|
||||
@ -111,7 +111,7 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
} catch (RateLimitException e) {
|
||||
final var message = CommandUtil.getRateLimitMessage(e);
|
||||
throw new RateLimitErrorException(message, e);
|
||||
} catch (TotpRequiredException 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(),
|
||||
@ -130,7 +130,7 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
throw new UserErrorException(message);
|
||||
} catch (NonNormalizedPhoneNumberException e) {
|
||||
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
|
||||
} catch (TotpRequiredException e) {
|
||||
} catch (TwoFactorRequired e) {
|
||||
throw new UserErrorException(
|
||||
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
|
||||
} catch (IOException e) {
|
||||
@ -146,10 +146,6 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
}
|
||||
|
||||
public record RegistrationParams(
|
||||
Boolean voice,
|
||||
String captcha,
|
||||
Boolean reregister,
|
||||
String recoveryKey,
|
||||
String totp
|
||||
Boolean voice, String captcha, Boolean reregister, String recoveryKey, String totp
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -58,6 +58,13 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerAddedHandler(final Consumer<Manager> handler) {
|
||||
synchronized (onManagerAddedHandlers) {
|
||||
onManagerAddedHandlers.remove(handler);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
||||
synchronized (onManagerRemovedHandlers) {
|
||||
@ -65,6 +72,13 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerRemovedHandler(final Consumer<Manager> handler) {
|
||||
synchronized (onManagerRemovedHandlers) {
|
||||
onManagerRemovedHandlers.remove(handler);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manager getManager(final String identifier) {
|
||||
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class),
|
||||
|
||||
@ -165,7 +165,10 @@ public class DbusReceiveMessageHandler implements Manager.ReceiveMessageHandler
|
||||
}
|
||||
if (message.remoteDeleteId().isPresent()) {
|
||||
extras.put("remoteDelete",
|
||||
new Variant<>(Map.of("timestamp", new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
|
||||
new Variant<>(Map.of("targetTimestamp",
|
||||
new Variant<>(message.remoteDeleteId().get()),
|
||||
"timestamp",
|
||||
new Variant<>(message.remoteDeleteId().get())), "a{sv}"));
|
||||
}
|
||||
if (message.sticker().isPresent()) {
|
||||
final var sticker = message.sticker().get();
|
||||
|
||||
@ -5,7 +5,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.TwoFactorRequired;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -59,7 +59,7 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, TotpRequiredException {
|
||||
) throws IOException, TwoFactorRequired {
|
||||
throw new IOException("Recovery-key registration is not supported over D-Bus");
|
||||
}
|
||||
|
||||
|
||||
@ -5,13 +5,13 @@ import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import org.asamk.signal.commands.Commands;
|
||||
import org.asamk.signal.json.JsonReceiveMessageHandler;
|
||||
import org.asamk.signal.jsonrpc.JsonRpcReader;
|
||||
import org.asamk.signal.jsonrpc.JsonRpcResponse;
|
||||
import org.asamk.signal.jsonrpc.JsonRpcSender;
|
||||
import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.MultiAccountManager;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.manager.api.Pair;
|
||||
import org.asamk.signal.util.Util;
|
||||
import org.slf4j.Logger;
|
||||
@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@ -34,6 +35,7 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
private final ObjectMapper objectMapper = Util.createJsonObjectMapper();
|
||||
|
||||
private final InetSocketAddress address;
|
||||
private final Duration keepAliveInterval;
|
||||
|
||||
private final SignalJsonRpcCommandHandler commandHandler;
|
||||
private final MultiAccountManager c;
|
||||
@ -41,17 +43,24 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
private HttpServer server;
|
||||
private final AtomicBoolean shutdown = new AtomicBoolean(false);
|
||||
private final Set<String> allowedHosts;
|
||||
private final SseEventBuffer eventBuffer = new SseEventBuffer(objectMapper);
|
||||
|
||||
public HttpServerHandler(final InetSocketAddress address, final Manager m) {
|
||||
public HttpServerHandler(final InetSocketAddress address, final Duration keepAliveInterval, final Manager m) {
|
||||
this.address = address;
|
||||
this.keepAliveInterval = keepAliveInterval;
|
||||
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
|
||||
this.c = null;
|
||||
this.m = m;
|
||||
this.allowedHosts = buildAllowedHosts(address);
|
||||
}
|
||||
|
||||
public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) {
|
||||
public HttpServerHandler(
|
||||
final InetSocketAddress address,
|
||||
final Duration keepAliveInterval,
|
||||
final MultiAccountManager c
|
||||
) {
|
||||
this.address = address;
|
||||
this.keepAliveInterval = keepAliveInterval;
|
||||
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
|
||||
this.c = c;
|
||||
this.m = null;
|
||||
@ -93,6 +102,7 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
// Increase this delay when https://bugs.openjdk.org/browse/JDK-8304065 is fixed
|
||||
server.stop(2);
|
||||
server = null;
|
||||
eventBuffer.close();
|
||||
shutdown.set(false);
|
||||
}
|
||||
}
|
||||
@ -199,22 +209,27 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
|
||||
// Flush HTTP response headers to the client immediately.
|
||||
// Without this, the JVM HttpServer buffers everything until a later write
|
||||
// in the keep-alive loop (15 s), causing clients with shorter timeouts
|
||||
// in the keep-alive loop, causing clients with shorter timeouts
|
||||
// (e.g. 10 s) to abort before receiving the initial response.
|
||||
httpExchange.getResponseBody().flush();
|
||||
|
||||
final var shouldStop = new AtomicBoolean(false);
|
||||
final var handlers = subscribeReceiveHandlers(managers, sender, () -> {
|
||||
shouldStop.set(true);
|
||||
synchronized (this) {
|
||||
this.notifyAll();
|
||||
}
|
||||
});
|
||||
managers.forEach(eventBuffer::record);
|
||||
final var client = eventBuffer.connect(managers,
|
||||
sender,
|
||||
httpExchange.getRequestHeaders().getFirst("Last-Event-ID"),
|
||||
() -> {
|
||||
shouldStop.set(true);
|
||||
synchronized (this) {
|
||||
this.notifyAll();
|
||||
}
|
||||
});
|
||||
final var handlers = subscribeReceiveHandlers(managers);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
synchronized (this) {
|
||||
wait(15_000);
|
||||
wait(keepAliveInterval.toMillis());
|
||||
}
|
||||
if (shouldStop.get() || shutdown.get()) {
|
||||
break;
|
||||
@ -227,6 +242,7 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
eventBuffer.disconnect(client);
|
||||
for (final var pair : handlers) {
|
||||
unsubscribeReceiveHandler(pair);
|
||||
}
|
||||
@ -276,19 +292,17 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
throw new AssertionError("Unreachable state");
|
||||
}
|
||||
|
||||
private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(
|
||||
final List<Manager> managers,
|
||||
final ServerSentEventSender sender,
|
||||
Callable unsubscribe
|
||||
) {
|
||||
/**
|
||||
* The events are sent by eventBuffer, these handlers only keep the managers receiving while the client is connected.
|
||||
*/
|
||||
private List<Pair<Manager, Manager.ReceiveMessageHandler>> subscribeReceiveHandlers(final List<Manager> managers) {
|
||||
return managers.stream().map(m1 -> {
|
||||
final var receiveMessageHandler = new JsonReceiveMessageHandler(m1, s -> {
|
||||
try {
|
||||
sender.sendEvent(null, "receive", List.of(objectMapper.writeValueAsString(s)));
|
||||
} catch (IOException e) {
|
||||
unsubscribe.call();
|
||||
// An anonymous class instead of a lambda, as every connection needs its own handler instance
|
||||
final var receiveMessageHandler = new Manager.ReceiveMessageHandler() {
|
||||
@Override
|
||||
public void handleMessage(final MessageEnvelope envelope, final Throwable e) {
|
||||
}
|
||||
});
|
||||
};
|
||||
m1.addReceiveHandler(receiveMessageHandler);
|
||||
return new Pair<>(m1, (Manager.ReceiveMessageHandler) receiveMessageHandler);
|
||||
}).toList();
|
||||
@ -300,11 +314,6 @@ public class HttpServerHandler implements AutoCloseable {
|
||||
m.removeReceiveHandler(handler);
|
||||
}
|
||||
|
||||
private interface Callable {
|
||||
|
||||
void call();
|
||||
}
|
||||
|
||||
private Set<String> buildAllowedHosts(final InetSocketAddress address) {
|
||||
final var s = new HashSet<String>();
|
||||
final var host = address == null ? null : address.getHostString();
|
||||
|
||||
153
src/main/java/org/asamk/signal/http/SseEventBuffer.java
Normal file
153
src/main/java/org/asamk/signal/http/SseEventBuffer.java
Normal file
@ -0,0 +1,153 @@
|
||||
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,7 +8,11 @@ import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "AdminDelete")
|
||||
public record JsonAdminDelete(
|
||||
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
|
||||
@Deprecated String targetAuthor,
|
||||
String targetAuthorNumber,
|
||||
String targetAuthorUuid,
|
||||
long targetTimestamp,
|
||||
@Deprecated long targetSentTimestamp
|
||||
) {
|
||||
|
||||
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
|
||||
@ -16,9 +20,13 @@ public record JsonAdminDelete(
|
||||
final var targetAuthor = address.getLegacyIdentifier();
|
||||
final var targetAuthorNumber = address.number().orElse(null);
|
||||
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
|
||||
final var targetSentTimestamp = adminDelete.targetSentTimestamp();
|
||||
final var targetTimestamp = adminDelete.targetSentTimestamp();
|
||||
|
||||
return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
|
||||
return new JsonAdminDelete(targetAuthor,
|
||||
targetAuthorNumber,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp,
|
||||
targetTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ record JsonDataMessage(
|
||||
.map(JsonPreview::from)
|
||||
.toList() : null;
|
||||
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
|
||||
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
|
||||
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get(), dataMessage.remoteDeleteId().get())
|
||||
: null;
|
||||
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
|
||||
.stream()
|
||||
|
||||
@ -3,4 +3,4 @@ package org.asamk.signal.json;
|
||||
import io.micronaut.jsonschema.JsonSchema;
|
||||
|
||||
@JsonSchema(title = "RemoteDelete")
|
||||
record JsonRemoteDelete(long timestamp) {}
|
||||
record JsonRemoteDelete(long targetTimestamp, @Deprecated long timestamp) {}
|
||||
|
||||
@ -26,8 +26,7 @@ import java.util.stream.StreamSupport;
|
||||
public class JsonRpcReader {
|
||||
|
||||
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 static final int MAX_JSON_STRING_LENGTH = ((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
|
||||
|
||||
private final JsonRpcSender jsonRpcSender;
|
||||
private final ObjectMapper objectMapper;
|
||||
@ -50,9 +49,10 @@ public class JsonRpcReader {
|
||||
|
||||
private static ObjectMapper createObjectMapper() {
|
||||
final var objectMapper = Util.createJsonObjectMapper();
|
||||
objectMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder()
|
||||
.maxStringLength(MAX_JSON_STRING_LENGTH)
|
||||
.build());
|
||||
objectMapper.getFactory()
|
||||
.setStreamReadConstraints(StreamReadConstraints.builder()
|
||||
.maxStringLength(MAX_JSON_STRING_LENGTH)
|
||||
.build());
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -61,17 +62,31 @@ public class SignalJsonRpcDispatcherHandler {
|
||||
public void handleConnection(final MultiAccountManager c) {
|
||||
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) {
|
||||
this.subscribeReceive(c.getManagers(), true);
|
||||
c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
|
||||
c.addOnManagerRemovedHandler(this::unsubscribeReceive);
|
||||
c.addOnManagerAddedHandler(onManagerAddedReceiveHandler);
|
||||
c.addOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
|
||||
}
|
||||
c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
||||
createReceiveHandler(m, subscriptionId, false))));
|
||||
c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
|
||||
createCallEventHandler(m, subscriptionId))));
|
||||
c.addOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
|
||||
c.addOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
|
||||
|
||||
handleConnection();
|
||||
try {
|
||||
handleConnection();
|
||||
} finally {
|
||||
if (!noReceiveOnStart) {
|
||||
c.removeOnManagerAddedHandler(onManagerAddedReceiveHandler);
|
||||
c.removeOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
|
||||
}
|
||||
c.removeOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
|
||||
c.removeOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
|
||||
}
|
||||
}
|
||||
|
||||
public void handleConnection(final Manager m) {
|
||||
|
||||
@ -1415,6 +1415,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "java.math.BigDecimal"
|
||||
},
|
||||
{
|
||||
"type": "java.math.BigInteger"
|
||||
},
|
||||
@ -1771,6 +1774,9 @@
|
||||
{
|
||||
"type": "kotlin.Any"
|
||||
},
|
||||
{
|
||||
"type": "kotlin.Array"
|
||||
},
|
||||
{
|
||||
"type": "kotlin.Boolean"
|
||||
},
|
||||
@ -1946,42 +1952,7 @@
|
||||
},
|
||||
{
|
||||
"type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"allPublicFields": true
|
||||
},
|
||||
{
|
||||
"type": "kotlinx.coroutines.AwaitAll"
|
||||
@ -2513,6 +2484,10 @@
|
||||
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
|
||||
"allDeclaredFields": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "aci",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "number",
|
||||
"parameterTypes": []
|
||||
@ -5118,6 +5093,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.bouncycastle.jcajce.provider.asymmetric.XMSS$Mappings",
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519",
|
||||
"methods": [
|
||||
@ -6566,6 +6550,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.UsernameNotAvailableException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.internal.BridgeChatListener",
|
||||
"jniAccessible": true
|
||||
@ -7184,6 +7180,9 @@
|
||||
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential",
|
||||
"allDeclaredFields": true
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse"
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
|
||||
"allDeclaredFields": true
|
||||
@ -7250,6 +7249,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.network.util.JsonUtil$UuidDeserializer",
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.storageservice.storage.protos.groups.AccessControl",
|
||||
"fields": [
|
||||
@ -8321,6 +8329,13 @@
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"int",
|
||||
"int"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -8416,6 +8431,17 @@
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String",
|
||||
"java.lang.String",
|
||||
"java.lang.String",
|
||||
"java.util.List",
|
||||
"java.util.List",
|
||||
"java.lang.Long"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -8463,6 +8489,12 @@
|
||||
"allDeclaredMethods": 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",
|
||||
"allDeclaredFields": true,
|
||||
@ -8471,7 +8503,13 @@
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage",
|
||||
"allDeclaredFields": true
|
||||
"allDeclaredFields": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "getBody",
|
||||
"parameterTypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.api.push.ServiceId",
|
||||
@ -8919,6 +8957,9 @@
|
||||
"allDeclaredMethods": true,
|
||||
"allDeclaredConstructors": true
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion"
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64",
|
||||
"methods": [
|
||||
@ -8928,6 +8969,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding",
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse",
|
||||
"allDeclaredFields": true,
|
||||
@ -8935,6 +8985,13 @@
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String",
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -8946,7 +9003,21 @@
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@ -9096,6 +9167,14 @@
|
||||
"name": "<init>",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"long",
|
||||
"org.signal.libsignal.protocol.kem.KEMPublicKey",
|
||||
"byte[]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getKeyId",
|
||||
"parameterTypes": []
|
||||
@ -9182,6 +9261,9 @@
|
||||
"allDeclaredMethods": true,
|
||||
"allDeclaredConstructors": true
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion"
|
||||
},
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts",
|
||||
"allDeclaredFields": true,
|
||||
@ -9576,6 +9658,67 @@
|
||||
{
|
||||
"type": "org.whispersystems.signalservice.internal.push.StaleDevices",
|
||||
"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": [
|
||||
{
|
||||
"name": "<init>",
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
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 -> {}));
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@ -44,7 +45,7 @@ class SseInitialFlushTest {
|
||||
void setUp() throws Exception {
|
||||
port = freePort();
|
||||
final Manager manager = ManagerMock.create("+10000000000");
|
||||
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), manager);
|
||||
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), Duration.ofSeconds(15), manager);
|
||||
handler.init();
|
||||
}
|
||||
|
||||
|
||||
201
src/test/java/org/asamk/signal/http/SseReplayTest.java
Normal file
201
src/test/java/org/asamk/signal/http/SseReplayTest.java
Normal file
@ -0,0 +1,201 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,6 +67,8 @@ class SubscribeCallEventsTest {
|
||||
|
||||
final List<Manager> managers;
|
||||
final List<Consumer<Manager>> addedHandlers = new ArrayList<>();
|
||||
final List<Consumer<Manager>> removedHandlers = new ArrayList<>();
|
||||
int addedHandlerRegistrationCount;
|
||||
|
||||
StubMultiAccountManager(List<Manager> managers) {
|
||||
this.managers = new ArrayList<>(managers);
|
||||
@ -79,11 +81,23 @@ class SubscribeCallEventsTest {
|
||||
|
||||
@Override
|
||||
public void addOnManagerAddedHandler(Consumer<Manager> handler) {
|
||||
addedHandlerRegistrationCount++;
|
||||
addedHandlers.add(handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerAddedHandler(Consumer<Manager> handler) {
|
||||
addedHandlers.remove(handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addOnManagerRemovedHandler(Consumer<Manager> handler) {
|
||||
removedHandlers.add(handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOnManagerRemovedHandler(Consumer<Manager> handler) {
|
||||
removedHandlers.remove(handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -223,8 +237,9 @@ class SubscribeCallEventsTest {
|
||||
|
||||
assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener");
|
||||
assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener");
|
||||
// Also registers an onManagerAdded handler for receive and one for call events
|
||||
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
|
||||
assertEquals(2, multi.addedHandlerRegistrationCount, "should register onManagerAdded handlers");
|
||||
assertEquals(0, multi.addedHandlers.size(), "should remove onManagerAdded handlers after connection closes");
|
||||
assertEquals(0, multi.removedHandlers.size(), "should not register onManagerRemoved handlers in manual mode");
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -263,4 +278,17 @@ class SubscribeCallEventsTest {
|
||||
manager1.state().addCallEventListenerCount.get(),
|
||||
"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,6 +9,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class ManagerMock {
|
||||
@ -24,7 +25,8 @@ public final class ManagerMock {
|
||||
public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0);
|
||||
public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0);
|
||||
|
||||
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new ArrayList<>();
|
||||
// Written by server threads while tests read it
|
||||
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new CopyOnWriteArrayList<>();
|
||||
public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0);
|
||||
public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user