From f6a5640b5df5fd7d3a5e5527a9fe3847e2707e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D1=8F?= Date: Tue, 29 Sep 2026 13:02:06 +0300 Subject: [PATCH] Expose history-aware device linking over daemon JSON-RPC --- .../signal/manager/MultiAccountManager.java | 7 ++ .../internal/MultiAccountManagerImpl.java | 7 +- .../signal/commands/ExportHistoryCommand.java | 46 +++++++- .../signal/commands/FinishLinkCommand.java | 4 +- .../signal/commands/StartLinkCommand.java | 15 ++- .../commands/DaemonHistoryAndLinkTest.java | 101 ++++++++++++++++++ 6 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/asamk/signal/commands/DaemonHistoryAndLinkTest.java 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..ec8f90b4 100644 --- a/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java @@ -18,6 +18,13 @@ public interface MultiAccountManager extends AutoCloseable { URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException; + default URI getNewProvisioningDeviceLinkUri(final boolean importHistory) throws TimeoutException, IOException { + if (importHistory) { + throw new UnsupportedOperationException("History transfer is not supported by this manager backend"); + } + return getNewProvisioningDeviceLinkUri(); + } + ProvisioningManager getProvisioningManagerFor(URI deviceLinkUri); RegistrationManager getNewRegistrationManager(String account) throws 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..981b010c 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 @@ -135,8 +135,13 @@ public class MultiAccountManagerImpl implements MultiAccountManager { @Override public URI getNewProvisioningDeviceLinkUri() throws TimeoutException, IOException { + return getNewProvisioningDeviceLinkUri(false); + } + + @Override + public URI getNewProvisioningDeviceLinkUri(final boolean importHistory) throws TimeoutException, IOException { final var provisioningManager = getNewProvisioningManager(); - final var deviceLinkUri = provisioningManager.getDeviceLinkUri(); + final var deviceLinkUri = provisioningManager.getDeviceLinkUri(importHistory); provisioningManagers.put(deviceLinkUri, provisioningManager); return deviceLinkUri; } diff --git a/src/main/java/org/asamk/signal/commands/ExportHistoryCommand.java b/src/main/java/org/asamk/signal/commands/ExportHistoryCommand.java index 40dce6b0..230c05ef 100644 --- a/src/main/java/org/asamk/signal/commands/ExportHistoryCommand.java +++ b/src/main/java/org/asamk/signal/commands/ExportHistoryCommand.java @@ -1,5 +1,7 @@ package org.asamk.signal.commands; +import com.fasterxml.jackson.core.type.TypeReference; + import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; @@ -7,6 +9,7 @@ import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.OutputType; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.manager.Manager; import org.asamk.signal.output.JsonWriter; @@ -15,10 +18,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.SequencedCollection; -public final class ExportHistoryCommand implements LocalCommand { +public final class ExportHistoryCommand implements LocalCommand, JsonRpcSingleCommand { private static final Logger logger = LoggerFactory.getLogger(ExportHistoryCommand.class); @@ -35,6 +39,42 @@ public final class ExportHistoryCommand implements LocalCommand { .help("Delete the encrypted transfer archive and key after a successful export."); } + @Override + public TypeReference getRequestType() { + return new TypeReference<>() {}; + } + + @Override + public void handleCommand( + final ExportHistoryParams request, + final Manager manager, + final JsonWriter jsonWriter + ) throws CommandException { + final var offset = request == null || request.offset() == null ? 0 : request.offset(); + final var limit = request == null || request.limit() == null ? 100 : request.limit(); + if (offset < 0 || limit < 1 || limit > 100) { + throw new UserErrorException( + "History offset must be non-negative and limit must be between 1 and 100"); + } + final var messages = new ArrayList(); + final var handler = new JsonReceiveMessageHandler(manager, messages::add); + final var seen = new long[] {0}; + final var pageHandler = (Manager.ReceiveMessageHandler) (envelope, exception) -> { + if (seen[0]++ >= offset && messages.size() < limit) { + handler.handleMessage(envelope, exception); + } + }; + try { + final var result = manager.exportHistory(pageHandler, false); + jsonWriter.write(new JsonHistoryPage(messages, + result.exportedMessages(), + result.skippedRecords(), + (long) offset + messages.size() < result.exportedMessages())); + } catch (IOException e) { + throw new IOErrorException("Failed to export linked-device history: " + e.getMessage(), e); + } + } + @Override public SequencedCollection getSupportedOutputTypes() { return List.of(OutputType.JSON); @@ -57,4 +97,8 @@ public final class ExportHistoryCommand implements LocalCommand { throw new IOErrorException("Failed to export linked-device history: " + e.getMessage(), e); } } + + public record ExportHistoryParams(Integer offset, Integer limit) {} + + private record JsonHistoryPage(List messages, int exportedMessages, int skippedRecords, boolean hasMore) {} } diff --git a/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java b/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java index 8a8f8454..876f0c20 100644 --- a/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java +++ b/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java @@ -56,7 +56,7 @@ public class FinishLinkCommand implements JsonRpcMultiCommand { +public class StartLinkCommand implements JsonRpcMultiCommand { private static final Logger logger = LoggerFactory.getLogger(StartLinkCommand.class); @@ -21,15 +23,20 @@ public class StartLinkCommand implements JsonRpcMultiCommand { return "startLink"; } + @Override + public TypeReference getRequestType() { + return new TypeReference<>() {}; + } + @Override public void handleCommand( - final Void request, + final StartLinkParams request, final MultiAccountManager m, final JsonWriter jsonWriter ) throws CommandException { final URI deviceLinkUri; try { - deviceLinkUri = m.getNewProvisioningDeviceLinkUri(); + deviceLinkUri = m.getNewProvisioningDeviceLinkUri(request != null && Boolean.TRUE.equals(request.importHistory())); } catch (TimeoutException e) { throw new UserErrorException("Device link creation timed out, please try again."); } catch (IOException e) { @@ -39,5 +46,7 @@ public class StartLinkCommand implements JsonRpcMultiCommand { jsonWriter.write(new JsonLink(deviceLinkUri.toString())); } + public record StartLinkParams(Boolean importHistory) {} + private record JsonLink(String deviceLinkUri) {} } diff --git a/src/test/java/org/asamk/signal/commands/DaemonHistoryAndLinkTest.java b/src/test/java/org/asamk/signal/commands/DaemonHistoryAndLinkTest.java new file mode 100644 index 00000000..140acdac --- /dev/null +++ b/src/test/java/org/asamk/signal/commands/DaemonHistoryAndLinkTest.java @@ -0,0 +1,101 @@ +package org.asamk.signal.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.MultiAccountManager; +import org.asamk.signal.manager.ProvisioningManager; +import org.asamk.signal.manager.api.HistoryExportResult; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.testutil.ManagerMock; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.net.URI; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DaemonHistoryAndLinkTest { + + @Test + void daemonLinkPassesHistoryFlagToBothProvisioningSteps() throws Exception { + final var started = new AtomicBoolean(); + final var finished = new AtomicBoolean(); + final var uri = URI.create("sgnl://linkdevice?uuid=test&pub_key=key"); + final var provisioning = (ProvisioningManager) Proxy.newProxyInstance( + ProvisioningManager.class.getClassLoader(), new Class[]{ProvisioningManager.class}, + (proxy, method, args) -> switch (method.getName()) { + case "finishDeviceLink" -> { + finished.set(Boolean.TRUE.equals(args[1])); + yield "+15551234567"; + } + default -> null; + }); + final var account = ManagerMock.create("+15551234567"); + final var multi = (MultiAccountManager) Proxy.newProxyInstance( + MultiAccountManager.class.getClassLoader(), new Class[]{MultiAccountManager.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getNewProvisioningDeviceLinkUri" -> { + started.set(Boolean.TRUE.equals(args[0])); + yield uri; + } + case "getProvisioningManagerFor" -> provisioning; + case "getManager" -> account; + default -> null; + }); + final var startResult = new AtomicReference(); + new StartLinkCommand().handleCommand(new StartLinkCommand.StartLinkParams(true), multi, startResult::set); + assertEquals(uri.toString(), new ObjectMapper().valueToTree(startResult.get()).get("deviceLinkUri").asText()); + final var finishResult = new AtomicReference(); + new FinishLinkCommand().handleCommand( + new FinishLinkCommand.FinishLinkParams(uri.toString(), "dev", true), multi, finishResult::set); + assertTrue(started.get()); + assertTrue(finished.get()); + assertEquals("+15551234567", new ObjectMapper().valueToTree(finishResult.get()).get("number").asText()); + } + + @Test + void daemonHistoryIsPagedAndLeavesArchiveForReplay() throws Exception { + final var retained = new AtomicBoolean(); + final var base = ManagerMock.create("+15551234567"); + final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(), + new Class[]{Manager.class}, (proxy, method, args) -> { + if (method.getName().equals("exportHistory")) { + retained.set(!Boolean.TRUE.equals(args[1])); + final var handler = (Manager.ReceiveMessageHandler) args[0]; + for (int i = 0; i < 3; i++) { + handler.handleMessage(null, null); + } + return new HistoryExportResult(3, 1); + } + return method.invoke(base, args); + }); + final var first = exportPage(manager, 0, 2); + final var last = exportPage(manager, 2, 2); + final var replayed = exportPage(manager, 0, 2); + final var exhausted = exportPage(manager, 3, 2); + assertTrue(retained.get()); + assertEquals(2, first.get("messages").size()); + assertTrue(first.get("hasMore").asBoolean()); + assertEquals(1, last.get("messages").size()); + assertFalse(last.get("hasMore").asBoolean()); + assertEquals(first, replayed); + assertEquals(0, exhausted.get("messages").size()); + assertFalse(exhausted.get("hasMore").asBoolean()); + assertEquals(3, first.get("exportedMessages").asInt()); + assertEquals(1, first.get("skippedRecords").asInt()); + assertFalse(first.get("messages").get(0).has("envelope")); + } + + private static JsonNode exportPage(final Manager manager, final int offset, final int limit) throws Exception { + final var output = new AtomicReference(); + new ExportHistoryCommand().handleCommand(new ExportHistoryCommand.ExportHistoryParams(offset, limit), + manager, output::set); + return new ObjectMapper().valueToTree(output.get()); + } +}