From 86a85cc239fc16a8516eae3909a8812fac306e82 Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 28 Sep 2026 18:52:35 +0200 Subject: [PATCH 01/22] Update gradle wrapper --- gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 52 ++++++++++++++++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 38775 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 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% From 76fb9768aa7e2f685b256abbbb176fbab2dd56a3 Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 28 Sep 2026 18:53:01 +0200 Subject: [PATCH 02/22] Update graalvm metadata Fixes #2131 --- .../signal-cli/reachability-metadata.json | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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..ffa1ee0e 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 @@ -9583,6 +9583,61 @@ } ] }, + { + "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": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.SubmitRecaptchaChallengePayload", "allDeclaredFields": true From 13d603d3b5898d7b3b527305afc23c9c7ba84c5b Mon Sep 17 00:00:00 2001 From: Romain Boulay Date: Tue, 29 Sep 2026 19:28:17 +0200 Subject: [PATCH 03/22] Add Last-Event-ID replay and configurable keep-alive to the SSE endpoint (#2133) Each event of /api/v1/events now has an id. The last 1000 events are kept in memory, and a client that reconnects with a Last-Event-ID header first gets the events it missed. The keep-alive interval can be set with the new daemon option --sse-keepalive-interval. Closes #2132 Co-authored-by: Romain Boulay <169323+RomainBoulay@users.noreply.github.com> --- man/signal-cli-jsonrpc.5.adoc | 5 + man/signal-cli.1.adoc | 4 + .../asamk/signal/commands/DaemonCommand.java | 30 ++- .../asamk/signal/http/HttpServerHandler.java | 63 +++--- .../org/asamk/signal/http/SseEventBuffer.java | 153 +++++++++++++ .../signal/http/SseInitialFlushTest.java | 3 +- .../org/asamk/signal/http/SseReplayTest.java | 201 ++++++++++++++++++ .../asamk/signal/testutil/ManagerMock.java | 4 +- 8 files changed, 428 insertions(+), 35 deletions(-) create mode 100644 src/main/java/org/asamk/signal/http/SseEventBuffer.java create mode 100644 src/test/java/org/asamk/signal/http/SseReplayTest.java 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/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 3bb05dea..fd64a9f6 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,10 @@ 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 +303,11 @@ 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 +331,11 @@ 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/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/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/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); } From 269f895101ac7354f7c26b992c6f47d359a62853 Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 17:35:59 +0200 Subject: [PATCH 04/22] Update dependencies --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e4fc5460..6761a21f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ 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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c9af028..037a126b 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" +micronaut-json-schema = "2.3.1" +micronaut-core = "5.2.14" signal-service = "2.15.3_unofficial_153" [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" } From da8c69b2a2a09aeab5f838d5d2951f0f47b13377 Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 17:39:11 +0200 Subject: [PATCH 05/22] Fix listener leaked per json-rpc connection Fixes #2136 Fixes #1653 --- .../signal/manager/MultiAccountManager.java | 4 +++ .../internal/MultiAccountManagerImpl.java | 14 ++++++++ .../dbus/DbusMultiAccountManagerImpl.java | 14 ++++++++ .../SignalJsonRpcDispatcherHandler.java | 29 +++++++++++++---- .../jsonrpc/SubscribeCallEventsTest.java | 32 +++++++++++++++++-- 5 files changed, 84 insertions(+), 9 deletions(-) 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/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/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/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 166c822a..65be9899 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/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"); + } } From ba389ba6bb442bdba55d18b97d877972451ede5d Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 17:50:49 +0200 Subject: [PATCH 06/22] Unify remote delete timestamp as targetTimestamp Fixes #2130 --- .../signal/dbus/DbusReceiveMessageHandler.java | 5 ++++- .../org/asamk/signal/json/JsonAdminDelete.java | 14 +++++++++++--- .../org/asamk/signal/json/JsonDataMessage.java | 2 +- .../org/asamk/signal/json/JsonRemoteDelete.java | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) 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/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) {} From 0dde3d859699d00824bcca7c048a4961f48d1198 Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 17:50:56 +0200 Subject: [PATCH 07/22] Reformat files --- .../manager/internal/RegistrationManagerImpl.java | 2 +- .../org/asamk/signal/commands/DaemonCommand.java | 15 +++------------ .../asamk/signal/commands/RegisterCommand.java | 6 +----- .../org/asamk/signal/jsonrpc/JsonRpcReader.java | 10 +++++----- .../jsonrpc/SignalJsonRpcDispatcherHandler.java | 8 ++++---- 5 files changed, 14 insertions(+), 27 deletions(-) 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..4a6edbb6 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 @@ -288,7 +288,7 @@ public class RegistrationManagerImpl implements RegistrationManager { 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); } } diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index fd64a9f6..3469c4e8 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -244,10 +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, - Duration keepAliveInterval - ) throws CommandException; + public abstract void runHttp(InetSocketAddress address, Duration keepAliveInterval) throws CommandException; protected final void runSocket(final SocketHandler socketHandler) { socketHandler.init(); @@ -303,10 +300,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand { } @Override - public void runHttp( - final InetSocketAddress address, - final Duration keepAliveInterval - ) throws CommandException { + public void runHttp(final InetSocketAddress address, final Duration keepAliveInterval) throws CommandException { runHttp(new HttpServerHandler(address, keepAliveInterval, m)); } } @@ -331,10 +325,7 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand { } @Override - public void runHttp( - final InetSocketAddress address, - final Duration keepAliveInterval - ) throws CommandException { + 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..50543963 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -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/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 65be9899..b959452b 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -64,10 +64,10 @@ public class SignalJsonRpcDispatcherHandler { 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))); + 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); From bd067a045d913806c34200f7377856a26810c44f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:20:17 +0200 Subject: [PATCH 08/22] Bump rustls from 0.23.44 to 0.23.45 in /client (#2138) Bumps [rustls](https://github.com/rustls/rustls) from 0.23.44 to 0.23.45. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.44...v/0.23.45) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.45 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/Cargo.lock b/client/Cargo.lock index 1c1ebce6..2ced763d 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", From ca2583c1b06ef72c6d63368e1396f7182fbfbf7b Mon Sep 17 00:00:00 2001 From: Dmytro Lomako Date: Tue, 6 Oct 2026 21:08:42 +0300 Subject: [PATCH 09/22] Improve attachment upload failure handling (#2135) --- .../manager/helper/AttachmentHelper.java | 191 +++++++++--- .../signal/manager/internal/ManagerImpl.java | 25 +- .../manager/helper/AttachmentHelperTest.java | 289 ++++++++++++++++++ man/signal-cli-dbus.5.adoc | 2 + .../signal/commands/SendCommandErrorTest.java | 59 ++++ 5 files changed, 515 insertions(+), 51 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/helper/AttachmentHelperTest.java create mode 100644 src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java 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/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 1b0ed529..2026c527 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(), 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/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/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 -> {})); + } +} From ebf24ee0b00be14ab243b036e46babe5e8579b7b Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 6 Oct 2026 21:10:53 +0300 Subject: [PATCH 10/22] Use account device IDs for calls and verify primary and linked configurations (#2128) * Fix voice calls from linked devices (cherry picked from commit f3e22de14e4463adb785fb797791bc826b765a10) * Verify primary and linked call configuration through account state --------- Co-authored-by: Daniel --- .../signal/manager/helper/CallManager.java | 6 ++- .../manager/helper/CallManagerTest.java | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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/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 // From 8c86d30655d94be43c0565c28bb0f5c1010587d7 Mon Sep 17 00:00:00 2001 From: AsamK Date: Tue, 6 Oct 2026 22:34:26 +0200 Subject: [PATCH 11/22] Update libsignal-service --- gradle/libs.versions.toml | 2 +- .../signal/manager/RegistrationManager.java | 6 +- ...dException.java => TwoFactorRequired.java} | 4 +- .../signal/manager/config/LiveConfig.java | 3 - .../signal/manager/config/StagingConfig.java | 3 - .../signal/manager/helper/AccountHelper.java | 4 +- .../signal/manager/helper/ProfileHelper.java | 2 +- .../signal/manager/helper/SendHelper.java | 3 +- .../internal/RegistrationManagerImpl.java | 17 +-- .../signal/manager/util/ProfileUtils.java | 14 ++- libsignal-version | 2 +- .../signal/commands/RegisterCommand.java | 6 +- .../dbus/DbusRegistrationManagerImpl.java | 4 +- .../signal-cli/reachability-metadata.json | 110 ++++++++++++------ 14 files changed, 107 insertions(+), 73 deletions(-) rename lib/src/main/java/org/asamk/signal/manager/api/{TotpRequiredException.java => TwoFactorRequired.java} (62%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 037a126b..b9c5bd5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coroutines = "1.11.0" junit = "6.1.3" micronaut-json-schema = "2.3.1" micronaut-core = "5.2.14" -signal-service = "2.15.3_unofficial_153" +signal-service = "2.15.3_unofficial_154" [libraries] bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86" 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/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/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/RegistrationManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java index 4a6edbb6..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,7 +284,7 @@ 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); @@ -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/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/libsignal-version b/libsignal-version index 5bd38158..89eba2c5 100644 --- a/libsignal-version +++ b/libsignal-version @@ -1 +1 @@ -0.102.1 +0.103.0 diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index 50543963..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) { 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/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 ffa1ee0e..afb0b520 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": [ @@ -7184,6 +7168,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 @@ -8321,6 +8308,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "int", + "int" + ] } ] }, @@ -8463,6 +8457,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 +8471,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 +8925,9 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion" + }, { "type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64", "methods": [ @@ -8928,6 +8937,15 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse", "allDeclaredFields": true, @@ -8935,6 +8953,13 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "java.lang.String" + ] } ] }, @@ -9096,6 +9121,14 @@ "name": "", "parameterTypes": [] }, + { + "name": "", + "parameterTypes": [ + "long", + "org.signal.libsignal.protocol.kem.KEMPublicKey", + "byte[]" + ] + }, { "name": "getKeyId", "parameterTypes": [] @@ -9182,6 +9215,9 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion" + }, { "type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts", "allDeclaredFields": true, From 3997429779095ff3a2d64c877126e84fce9852aa Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 7 Oct 2026 12:43:39 +0200 Subject: [PATCH 12/22] Bump version to 0.14.9 --- CHANGELOG.md | 10 +++++++++- build.gradle.kts | 2 +- client/Cargo.lock | 2 +- client/Cargo.toml | 2 +- data/org.asamk.SignalCli.metainfo.xml | 3 +++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b091909f..d9d7d0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,18 @@ # Changelog -## [Unreleased] +## [0.14.9] - 2026-10-07 ### Added - `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded +- Support linking phone-numberless Signal accounts +- Support recovering accounts with a recovery key +- Support replaying missed SSE events and configuring keep-alive intervals + +### Improved + +- Show the actual error for failed requests +- Improve attachment upload and download failure handling ## [0.14.8] - 2026-09-10 diff --git a/build.gradle.kts b/build.gradle.kts index 6761a21f..fb1a4151 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { allprojects { group = "org.asamk" - version = "0.14.9-SNAPSHOT" + version = "0.14.9" } java { diff --git a/client/Cargo.lock b/client/Cargo.lock index 2ced763d..261edc8f 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -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 From 2a9a4b1f3cbb030e10f3bdeb26df7f666d44ccf3 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 7 Oct 2026 12:46:52 +0200 Subject: [PATCH 13/22] Prepare next release --- CHANGELOG.md | 2 ++ build.gradle.kts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d7d0f8..9e99fee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.14.9] - 2026-10-07 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index fb1a4151..6afe4163 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { allprojects { group = "org.asamk" - version = "0.14.9" + version = "0.14.10-SNAPSHOT" } java { From 6f2ea8006024013fdbbeb3317e02044a84fe5834 Mon Sep 17 00:00:00 2001 From: iddo Date: Fri, 9 Oct 2026 17:28:42 +0300 Subject: [PATCH 14/22] Fix race between stopping and starting the receive thread (#2142) * Fix race between stopping and starting the receive thread When the last receive handler was removed, receiveThread was cleared before the thread had stopped. A handler added in that window, e.g. by a client reconnecting to the daemon in on-connection receive mode, started a second receive thread. While the stop request for the old thread was still set, each new thread ended immediately and started the next one. When the old thread ended, it cleared the reference to the newer thread and started yet another one, leaving two receive loops on the same websocket. The unreferenced loop was never stopped and kept receiving and acknowledging messages after all clients had gone. Keep receiveThread set until the receive thread has finished and only let the thread itself clear it. Its end block already starts a new receive thread if handlers were added in the meantime. Clear a stop request right before a new receive loop is started instead of when a loop ends, so it can neither be cleared by another loop nor stop a later one. Co-Authored-By: Claude Opus 5.5 * Add test for adding a receive handler while the receive thread stops The receive loop is replaced by a fake one that can be held while it stops, so the test doesn't connect to the server. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: iddo <917594+iddo@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../signal/manager/helper/ReceiveHelper.java | 14 +- .../signal/manager/internal/ManagerImpl.java | 19 +- .../manager/internal/ReceiveThreadTest.java | 256 ++++++++++++++++++ 3 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/internal/ReceiveThreadTest.java 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/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 2026c527..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 @@ -1510,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()) { @@ -1529,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 -> { @@ -1551,7 +1561,6 @@ public class ManagerImpl implements Manager { return; } thread = receiveThread; - receiveThread = null; } stopReceiveThread(thread); @@ -1607,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) -> { @@ -2036,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/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; + } + } + } +} From 3aeaa34baa87c3ad97d6da4c04889dc2aa7cdfb9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:00:13 +0200 Subject: [PATCH 15/22] Download attachments from synced message edits (#2143) * Initial plan * Handle attachments in synced edits Co-authored-by: AsamK <2340865+AsamK@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: AsamK <2340865+AsamK@users.noreply.github.com> --- .../manager/helper/IncomingMessageHandler.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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(), From 32f652efc95d80f7a57859053430fedf635ab84e Mon Sep 17 00:00:00 2001 From: AsamK Date: Fri, 9 Oct 2026 17:35:36 +0200 Subject: [PATCH 16/22] Update libsignal-service Fixes #2140 --- gradle/libs.versions.toml | 2 +- .../manager/internal/ProvisioningManagerImpl.java | 3 +-- .../signal/manager/internal/SignalDependencies.java | 13 ++++++++++--- .../internal/NumberlessProvisioningTest.java | 2 ++ libsignal-version | 2 +- src/main/java/org/asamk/signal/BaseConfig.java | 2 +- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9c5bd5c..a57b9785 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coroutines = "1.11.0" junit = "6.1.3" micronaut-json-schema = "2.3.1" micronaut-core = "5.2.14" -signal-service = "2.15.3_unofficial_154" +signal-service = "2.15.3_unofficial_155" [libraries] bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.86" 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/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/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java index 1510a871..c5247dbe 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(), + () -> null, 1000L, new SecureRandom(), client); diff --git a/libsignal-version b/libsignal-version index 89eba2c5..f1b9cc4c 100644 --- a/libsignal-version +++ b/libsignal-version @@ -1 +1 @@ -0.103.0 +0.105.0 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; From f7c1ae1034fa43213e4cfb63ae7514d6cf7e6206 Mon Sep 17 00:00:00 2001 From: AsamK Date: Sat, 10 Oct 2026 08:52:48 +0200 Subject: [PATCH 17/22] Fix test --- .../signal/manager/internal/NumberlessProvisioningTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c5247dbe..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 @@ -123,7 +123,7 @@ class NumberlessProvisioningTest { null, false, config.signalServiceConfiguration(), - () -> null, + () -> false, 1000L, new SecureRandom(), client); From 1da2a51c1e68c21871a96046ac8258ba1edd72e7 Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 12 Oct 2026 10:44:44 +0200 Subject: [PATCH 18/22] Update graalvm metadata Fixes #2145 --- .../signal-cli/reachability-metadata.json | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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 afb0b520..6a7287c6 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 @@ -6550,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 @@ -7237,6 +7249,15 @@ } ] }, + { + "type": "org.signal.network.util.JsonUtil$UuidDeserializer", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.signal.storageservice.storage.protos.groups.AccessControl", "fields": [ @@ -8971,7 +8992,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", From c3b0fe902e7e38f451d77262c8a9f14e17fee8f7 Mon Sep 17 00:00:00 2001 From: wakamex Date: Wed, 14 Oct 2026 05:56:36 -0400 Subject: [PATCH 19/22] Accept group invitations addressed to the PNI (#2146) An invitation by phone number lists the account's PNI as the pending member. acceptInvite compared pending members only with the self ACI address and failed with 'Cannot find service ID for self to accept invite'. It now matches either the ACI or the PNI, makes the change as the ACI, and for a PNI promotion decrypts the server's signed change, since the locally built action has no member ciphertexts to decrypt. --- .../signal/manager/helper/GroupV2Helper.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) 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..456c56e4 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,6 +636,22 @@ class GroupV2Helper { final DecryptedGroupChange decryptedChange; final DecryptedGroup decryptedGroupState; + if (!changeActions.promote_members_pending_pni_aci_profile_key.isEmpty()) { + // The server fills in the ACI and PNI ciphertexts of a PNI promotion, so decrypt its signed change + final var signedGroupChange = dependencies.getGroupsV2Api() + .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty()); + final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier(); + try { + decryptedChange = groupOperations.decryptChange(signedGroupChange.group_change, + DecryptChangeVerificationMode.verify(groupId)).orElseThrow(); + decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); + } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { + throw new IOException(e); + } + groupInfoV2.setGroup(decryptedGroupState); + return new Pair<>(decryptedGroupState, signedGroupChange); + } + try { decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci()); decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); From c2b9c25b7ce6297ab840e3193a65258e0ffed239 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 14 Oct 2026 11:57:50 +0200 Subject: [PATCH 20/22] Always use server signed group change for updating local group --- .../signal/manager/helper/GroupV2Helper.java | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) 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 456c56e4..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 @@ -636,32 +636,18 @@ class GroupV2Helper { final DecryptedGroupChange decryptedChange; final DecryptedGroup decryptedGroupState; - if (!changeActions.promote_members_pending_pni_aci_profile_key.isEmpty()) { - // The server fills in the ACI and PNI ciphertexts of a PNI promotion, so decrypt its signed change - final var signedGroupChange = dependencies.getGroupsV2Api() - .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty()); - final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier(); - try { - decryptedChange = groupOperations.decryptChange(signedGroupChange.group_change, - DecryptChangeVerificationMode.verify(groupId)).orElseThrow(); - decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); - } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { - throw new IOException(e); - } - groupInfoV2.setGroup(decryptedGroupState); - return new Pair<>(decryptedGroupState, signedGroupChange); - } + 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); From f33ede265945b186af7fb39dddaa0ada46aec807 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 14 Oct 2026 17:43:13 +0200 Subject: [PATCH 21/22] Update graalvm metadata Fixes #2148 --- run_tests.sh | 1 + .../org.asamk/signal-cli/reachability-metadata.json | 6 ++++++ 2 files changed, 7 insertions(+) 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/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 6a7287c6..a369dad7 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 @@ -9651,6 +9651,12 @@ { "name": "", "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.util.List" + ] } ] }, From f107b8ff07881fbfe9700287e30e098ca9b17011 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 14 Oct 2026 19:36:21 +0200 Subject: [PATCH 22/22] Update graalvm metadata Fixes #2144 --- .../org.asamk/signal-cli/reachability-metadata.json | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 a369dad7..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 @@ -8431,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" + ] } ] },