diff --git a/CHANGELOG.md b/CHANGELOG.md index b091909f..9e99fee7 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/build.gradle.kts b/build.gradle.kts index e4fc5460..6afe4163 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/client/Cargo.lock b/client/Cargo.lock index 1c1ebce6..261edc8f 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index c026935c..6848a0a9 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -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 diff --git a/data/org.asamk.SignalCli.metainfo.xml b/data/org.asamk.SignalCli.metainfo.xml index 26cf0665..015d3c1f 100644 --- a/data/org.asamk.SignalCli.metainfo.xml +++ b/data/org.asamk.SignalCli.metainfo.xml @@ -45,6 +45,9 @@ intense + + https://github.com/AsamK/signal-cli/releases/tag/v0.14.9 + https://github.com/AsamK/signal-cli/releases/tag/v0.14.8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c9af028..a57b9785 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2e..5097068a 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ad7845be..9b7cb4af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -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% diff --git a/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java b/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java index 5d83819b..53402d77 100644 --- a/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java @@ -12,8 +12,12 @@ public interface MultiAccountManager extends AutoCloseable { void addOnManagerAddedHandler(Consumer handler); + void removeOnManagerAddedHandler(Consumer handler); + void addOnManagerRemovedHandler(Consumer handler); + void removeOnManagerRemovedHandler(Consumer handler); + Manager getManager(String phoneNumber); URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException; diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java index 7e89dcc3..fe88f2c3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -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; diff --git a/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java b/lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java similarity index 62% rename from lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java rename to lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java index a85e43ba..ec5cac43 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/TotpRequiredException.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/TwoFactorRequired.java @@ -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"); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java index 13f2ece8..56163a4a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java @@ -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 = Optional.empty(); private static final Optional proxy = Optional.empty(); - private static final Optional 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, diff --git a/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java index e6b1673b..10f9c684 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/StagingConfig.java @@ -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 = Optional.empty(); private static final Optional proxy = Optional.empty(); - private static final Optional 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, diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java index 210a5906..06cc46cb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java @@ -560,9 +560,9 @@ public class AccountHelper { final List 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()); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java index 82d8a9c4..f0aba31b 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java @@ -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 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 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 uploadAttachments( final List attachments, final List dimensions, final List 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(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 uploadAttachments(final List attachments) throws AttachmentInvalidException, IOException { + public List uploadAttachments(final List attachments) throws AttachmentInvalidException { return uploadAttachments(attachments, List.of(), List.of(), false); } @@ -82,45 +103,55 @@ public class AttachmentHelper { List dimensions, List blurHashes, boolean voiceNote - ) throws AttachmentInvalidException, IOException { + ) throws AttachmentInvalidException { if (attachments == null) { - return null; + return List.of(); } final var signalServiceAttachments = new ArrayList(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> 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 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); + } + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index 3d8efb72..de97899e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -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); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index 0ed6d4b2..a8fc01b1 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -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); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 7af7b28c..cf77709a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -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(), diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java index 6abe6677..e9ad8943 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java @@ -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); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java index 8d44f4fb..f97bd18b 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ReceiveHelper.java @@ -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; } } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index bd1188af..fd898bec 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -555,7 +555,8 @@ public class SendHelper { recipients, unidentifiedAccess, groupSendEndorsements, - message), + message, + null), recipientIds, groupInfo, false); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 1b0ed529..8658f365 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -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(); 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(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); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java index c767afba..85200f41 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java @@ -68,6 +68,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager { } } + @Override + public void removeOnManagerAddedHandler(final Consumer 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 handler) { + synchronized (onManagerRemovedHandlers) { + onManagerRemovedHandlers.remove(handler); + } + } + @Override public Manager getManager(final String identifier) { synchronized (managers) { diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java index c92aec12..64b4b13a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java @@ -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) { diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java index 075653bb..77b004ee 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java @@ -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 { diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java index 7add6211..ad4151c2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java @@ -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 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(), diff --git a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java index 0b5b418a..4f6c3126 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java @@ -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 getCapabilities(final SignalServiceProfile encryptedProfile) { final var capabilities = new HashSet(); - 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; diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/AttachmentHelperTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/AttachmentHelperTest.java new file mode 100644 index 00000000..987ca977 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/AttachmentHelperTest.java @@ -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 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()); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java index d7731411..cd566a05 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -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 // diff --git a/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java index 1510a871..9f6b4763 100644 --- a/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java @@ -122,6 +122,8 @@ class NumberlessProvisioningTest { "signal-cli-test", null, false, + config.signalServiceConfiguration(), + () -> false, 1000L, new SecureRandom(), client); diff --git a/lib/src/test/java/org/asamk/signal/manager/internal/ReceiveThreadTest.java b/lib/src/test/java/org/asamk/signal/manager/internal/ReceiveThreadTest.java new file mode 100644 index 00000000..63142cc1 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/internal/ReceiveThreadTest.java @@ -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 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; + } + } + } +} diff --git a/libsignal-version b/libsignal-version index 5bd38158..f1b9cc4c 100644 --- a/libsignal-version +++ b/libsignal-version @@ -1 +1 @@ -0.102.1 +0.105.0 diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index 7b4a754e..1f7639ad 100644 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -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 + diff --git a/man/signal-cli-jsonrpc.5.adoc b/man/signal-cli-jsonrpc.5.adoc index b9162925..8d86ba2f 100644 --- a/man/signal-cli-jsonrpc.5.adoc +++ b/man/signal-cli-jsonrpc.5.adoc @@ -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. diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 963d3c2c..c2a8d7c6 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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. diff --git a/run_tests.sh b/run_tests.sh index 42d4f466..dbda2541 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -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 diff --git a/src/main/java/org/asamk/signal/BaseConfig.java b/src/main/java/org/asamk/signal/BaseConfig.java index 0be50fd8..065167bc 100644 --- a/src/main/java/org/asamk/signal/BaseConfig.java +++ b/src/main/java/org/asamk/signal/BaseConfig.java @@ -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; diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 3bb05dea..3469c4e8 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -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)); } } } diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index ff458f77..5d7bf212 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -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 ) {} } diff --git a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java index da88343b..6c2fc4b0 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java @@ -58,6 +58,13 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { } } + @Override + public void removeOnManagerAddedHandler(final Consumer handler) { + synchronized (onManagerAddedHandlers) { + onManagerAddedHandlers.remove(handler); + } + } + @Override public void addOnManagerRemovedHandler(final Consumer handler) { synchronized (onManagerRemovedHandlers) { @@ -65,6 +72,13 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { } } + @Override + public void removeOnManagerRemovedHandler(final Consumer handler) { + synchronized (onManagerRemovedHandlers) { + onManagerRemovedHandlers.remove(handler); + } + } + @Override public Manager getManager(final String identifier) { return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class), diff --git a/src/main/java/org/asamk/signal/dbus/DbusReceiveMessageHandler.java b/src/main/java/org/asamk/signal/dbus/DbusReceiveMessageHandler.java index 29f4a4c9..448230cb 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/dbus/DbusReceiveMessageHandler.java @@ -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(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java index ee1a04f8..91b801f1 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusRegistrationManagerImpl.java @@ -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"); } diff --git a/src/main/java/org/asamk/signal/http/HttpServerHandler.java b/src/main/java/org/asamk/signal/http/HttpServerHandler.java index 11dd447f..fc5c9e0b 100644 --- a/src/main/java/org/asamk/signal/http/HttpServerHandler.java +++ b/src/main/java/org/asamk/signal/http/HttpServerHandler.java @@ -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 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> subscribeReceiveHandlers( - final List 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> subscribeReceiveHandlers(final List 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 buildAllowedHosts(final InetSocketAddress address) { final var s = new HashSet(); final var host = address == null ? null : address.getHostString(); diff --git a/src/main/java/org/asamk/signal/http/SseEventBuffer.java b/src/main/java/org/asamk/signal/http/SseEventBuffer.java new file mode 100644 index 00000000..00355202 --- /dev/null +++ b/src/main/java/org/asamk/signal/http/SseEventBuffer.java @@ -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. + *

+ * 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 recorders = new HashMap<>(); + private final ArrayDeque events = new ArrayDeque<>(); + private final List 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 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 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 managers; + private final ServerSentEventSender sender; + private final Runnable onClosed; + + private Client( + final Collection 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(); + } + } + } +} diff --git a/src/main/java/org/asamk/signal/json/JsonAdminDelete.java b/src/main/java/org/asamk/signal/json/JsonAdminDelete.java index c8586c9e..42b24c10 100644 --- a/src/main/java/org/asamk/signal/json/JsonAdminDelete.java +++ b/src/main/java/org/asamk/signal/json/JsonAdminDelete.java @@ -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); } } diff --git a/src/main/java/org/asamk/signal/json/JsonDataMessage.java b/src/main/java/org/asamk/signal/json/JsonDataMessage.java index a053945b..f2ea5b5b 100644 --- a/src/main/java/org/asamk/signal/json/JsonDataMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonDataMessage.java @@ -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() diff --git a/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java index b1def8e8..1a870fa9 100644 --- a/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java +++ b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java @@ -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) {} diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java index cb40f327..468637ca 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java +++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java @@ -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; } diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 166c822a..b959452b 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -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 onManagerAddedReceiveHandler = m -> subscribeReceive(m, true); + final Consumer onManagerRemovedReceiveHandler = this::unsubscribeReceive; + final Consumer onManagerAddedReceiveSubscriptionsHandler = m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add( + createReceiveHandler(m, subscriptionId, false))); + final Consumer 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) { diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index 60ff4c40..4af3819a 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -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": "", + "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": "", + "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": "", + "parameterTypes": [] + } + ] + }, { "type": "org.signal.storageservice.storage.protos.groups.AccessControl", "fields": [ @@ -8321,6 +8329,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "int", + "int" + ] } ] }, @@ -8416,6 +8431,17 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "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": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse", "allDeclaredFields": true, @@ -8935,6 +8985,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "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": "", "parameterTypes": [] }, + { + "name": "", + "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": "", + "parameterTypes": [] + }, + { + "name": "", + "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": "", + "parameterTypes": [] + } + ] + }, + { + "type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributesResponse", + "fields": [ + { + "name": "manifest" + }, + { + "name": "packId" + }, + { + "name": "stickers" + } + ], "methods": [ { "name": "", diff --git a/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java b/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java new file mode 100644 index 00000000..a2105437 --- /dev/null +++ b/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java @@ -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 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 -> {})); + } +} diff --git a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java index 472a8fef..2153a399 100644 --- a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java +++ b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java @@ -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(); } diff --git a/src/test/java/org/asamk/signal/http/SseReplayTest.java b/src/test/java/org/asamk/signal/http/SseReplayTest.java new file mode 100644 index 00000000..dd65f594 --- /dev/null +++ b/src/test/java/org/asamk/signal/http/SseReplayTest.java @@ -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(); + } + } +} diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 9ec1c87d..cce6c888 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -67,6 +67,8 @@ class SubscribeCallEventsTest { final List managers; final List> addedHandlers = new ArrayList<>(); + final List> removedHandlers = new ArrayList<>(); + int addedHandlerRegistrationCount; StubMultiAccountManager(List managers) { this.managers = new ArrayList<>(managers); @@ -79,11 +81,23 @@ class SubscribeCallEventsTest { @Override public void addOnManagerAddedHandler(Consumer handler) { + addedHandlerRegistrationCount++; addedHandlers.add(handler); } + @Override + public void removeOnManagerAddedHandler(Consumer handler) { + addedHandlers.remove(handler); + } + @Override public void addOnManagerRemovedHandler(Consumer handler) { + removedHandlers.add(handler); + } + + @Override + public void removeOnManagerRemovedHandler(Consumer 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"); + } } diff --git a/src/test/java/org/asamk/signal/testutil/ManagerMock.java b/src/test/java/org/asamk/signal/testutil/ManagerMock.java index d10bdd60..7022e967 100644 --- a/src/test/java/org/asamk/signal/testutil/ManagerMock.java +++ b/src/test/java/org/asamk/signal/testutil/ManagerMock.java @@ -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 receiveHandlers = new ArrayList<>(); + // Written by server threads while tests read it + public final List receiveHandlers = new CopyOnWriteArrayList<>(); public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0); public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0); }