Expose history-aware device linking over daemon JSON-RPC

This commit is contained in:
Даня 2026-09-29 13:02:06 +03:00 committed by Даня
parent 6971d29f60
commit f6a5640b5d
6 changed files with 173 additions and 7 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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<ExportHistoryCommand.ExportHistoryParams> {
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<ExportHistoryParams> 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<Object>();
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<OutputType> 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<Object> messages, int exportedMessages, int skippedRecords, boolean hasMore) {}
}

View File

@ -56,7 +56,7 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
}
final String identifier;
try {
identifier = provisioningManager.finishDeviceLink(deviceName);
identifier = provisioningManager.finishDeviceLink(deviceName, Boolean.TRUE.equals(request.importHistory()));
} catch (TimeoutException e) {
throw new UserErrorException("Link request timed out, please try again.");
} catch (IOException e) {
@ -77,7 +77,7 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
jsonWriter.write(new JsonFinishLink(manager.getSelfNumber(), manager.getSelfACI()));
}
public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
public record FinishLinkParams(String deviceLinkUri, String deviceName, Boolean importHistory) {}
private record JsonFinishLink(String number, String aci) {}
}

View File

@ -1,5 +1,7 @@
package org.asamk.signal.commands;
import com.fasterxml.jackson.core.type.TypeReference;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
@ -12,7 +14,7 @@ import java.io.IOException;
import java.net.URI;
import java.util.concurrent.TimeoutException;
public class StartLinkCommand implements JsonRpcMultiCommand<Void> {
public class StartLinkCommand implements JsonRpcMultiCommand<StartLinkCommand.StartLinkParams> {
private static final Logger logger = LoggerFactory.getLogger(StartLinkCommand.class);
@ -21,15 +23,20 @@ public class StartLinkCommand implements JsonRpcMultiCommand<Void> {
return "startLink";
}
@Override
public TypeReference<StartLinkParams> 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<Void> {
jsonWriter.write(new JsonLink(deviceLinkUri.toString()));
}
public record StartLinkParams(Boolean importHistory) {}
private record JsonLink(String deviceLinkUri) {}
}

View File

@ -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<Object>();
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<Object>();
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<Object>();
new ExportHistoryCommand().handleCommand(new ExportHistoryCommand.ExportHistoryParams(offset, limit),
manager, output::set);
return new ObjectMapper().valueToTree(output.get());
}
}