mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-10-25 08:39:26 +00:00
Accept caller-supplied attachment dimensions
Forward positional WIDTHxHEIGHT metadata supplied by the caller. Validate dimensions before sending and retain automatic image detection for omitted entries.
This commit is contained in:
parent
a255a7fecf
commit
f97cbab1fe
@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `send --attachment-dimensions` to supply displayed attachment dimensions for correctly sized video placeholders
|
||||
|
||||
## [0.14.8] - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
@ -6,6 +6,7 @@ import java.util.Optional;
|
||||
public record Message(
|
||||
String messageText,
|
||||
List<String> attachments,
|
||||
List<AttachmentDimensions> attachmentDimensions,
|
||||
boolean viewOnce,
|
||||
boolean voiceNote,
|
||||
List<Mention> mentions,
|
||||
@ -17,6 +18,17 @@ public record Message(
|
||||
boolean urgent
|
||||
) {
|
||||
|
||||
public record AttachmentDimensions(int width, int height) {
|
||||
|
||||
public static final AttachmentDimensions UNKNOWN = new AttachmentDimensions(0, 0);
|
||||
|
||||
public AttachmentDimensions {
|
||||
if (width < 0 || height < 0 || (width == 0) != (height == 0)) {
|
||||
throw new IllegalArgumentException("Dimensions must both be positive or both zero");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
|
||||
|
||||
public record Quote(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.internal.SignalDependencies;
|
||||
import org.asamk.signal.manager.storage.AttachmentStore;
|
||||
@ -52,9 +53,10 @@ public class AttachmentHelper {
|
||||
|
||||
public List<SignalServiceAttachment> uploadAttachments(
|
||||
final List<String> attachments,
|
||||
final List<AttachmentDimensions> dimensions,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
|
||||
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, voiceNote);
|
||||
|
||||
try {
|
||||
// Upload attachments here, so we only upload once even for multiple recipients
|
||||
@ -70,27 +72,25 @@ public class AttachmentHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
|
||||
return uploadAttachments(attachments, false);
|
||||
}
|
||||
|
||||
private List<SignalServiceAttachmentStream> createAttachmentStreams(
|
||||
List<String> attachments,
|
||||
List<AttachmentDimensions> dimensions,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
if (attachments == null) {
|
||||
return null;
|
||||
}
|
||||
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
|
||||
for (var attachment : attachments) {
|
||||
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
|
||||
signalServiceAttachments.add(attachmentStream);
|
||||
for (var i = 0; i < attachments.size(); i++) {
|
||||
final var size = i < dimensions.size() ? dimensions.get(i) : AttachmentDimensions.UNKNOWN;
|
||||
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, voiceNote));
|
||||
}
|
||||
return signalServiceAttachments;
|
||||
}
|
||||
|
||||
private SignalServiceAttachmentStream getAttachmentStream(
|
||||
final String attachment,
|
||||
final AttachmentDimensions dimensions,
|
||||
final boolean voiceNote
|
||||
) throws AttachmentInvalidException {
|
||||
try {
|
||||
@ -116,6 +116,7 @@ public class AttachmentHelper {
|
||||
return AttachmentUtils.createAttachmentStream(streamDetails,
|
||||
streamDetailsAndFileName.second(),
|
||||
voiceNote,
|
||||
dimensions,
|
||||
uploadSpec);
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
@ -130,7 +131,7 @@ public class AttachmentHelper {
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
|
||||
final var attachmentStream = getAttachmentStream(attachment, false);
|
||||
final var attachmentStream = getAttachmentStream(attachment, AttachmentDimensions.UNKNOWN, false);
|
||||
return uploadAttachment(attachmentStream);
|
||||
}
|
||||
|
||||
|
||||
@ -970,7 +970,7 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
if (!message.attachments().isEmpty()) {
|
||||
final var uploadedAttachments = context.getAttachmentHelper()
|
||||
.uploadAttachments(message.attachments(), message.voiceNote());
|
||||
.uploadAttachments(message.attachments(), message.attachmentDimensions(), message.voiceNote());
|
||||
if (!additionalAttachments.isEmpty()) {
|
||||
additionalAttachments.addAll(uploadedAttachments);
|
||||
messageBuilder.withAttachments(additionalAttachments);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
@ -27,6 +28,7 @@ public class AttachmentUtils {
|
||||
StreamDetails streamDetails,
|
||||
Optional<String> name,
|
||||
boolean voiceNote,
|
||||
AttachmentDimensions dimensions,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
final var uploadTimestamp = System.currentTimeMillis();
|
||||
@ -37,8 +39,8 @@ public class AttachmentUtils {
|
||||
.withLength(streamDetails.getLength())
|
||||
.withFileName(name.orElse(null))
|
||||
.withVoiceNote(voiceNote)
|
||||
.withWidth(probedStream.width())
|
||||
.withHeight(probedStream.height())
|
||||
.withWidth(dimensions.width() > 0 ? dimensions.width() : probedStream.width())
|
||||
.withHeight(dimensions.height() > 0 ? dimensions.height() : probedStream.height())
|
||||
.withUploadTimestamp(uploadTimestamp)
|
||||
.withResumableUploadSpec(resumableUploadSpec)
|
||||
.withUuid(UUID.randomUUID())
|
||||
@ -50,7 +52,7 @@ public class AttachmentUtils {
|
||||
Optional<String> name,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
|
||||
return createAttachmentStream(streamDetails, name, false, AttachmentDimensions.UNKNOWN, resumableUploadSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
@ -43,6 +44,19 @@ class AttachmentUtilsTest {
|
||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAttachmentStream_setsSuppliedVideoDimensionsWithoutReadingVideo() throws Exception {
|
||||
final var bytes = "opaque video bytes".getBytes();
|
||||
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
|
||||
final var attachment = AttachmentUtils.createAttachmentStream(details,
|
||||
Optional.of("clip.mp4"), false, new AttachmentDimensions(1080, 1920), null);
|
||||
|
||||
assertEquals(1080, attachment.getWidth());
|
||||
assertEquals(1920, attachment.getHeight());
|
||||
assertEquals(Optional.empty(), attachment.getBlurHash());
|
||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(final int width, final int height) throws Exception {
|
||||
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
final var out = new ByteArrayOutputStream();
|
||||
|
||||
@ -344,6 +344,13 @@ Data URI encoded attachments must follow the RFC 2397.
|
||||
Additionally a file name can be added:
|
||||
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
|
||||
|
||||
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
|
||||
Specify positive integer display dimensions for the attachment at the same position in `--attachment`.
|
||||
Use dimensions after applying video rotation. An empty string or omitted trailing entry keeps automatic image dimension detection; video dimensions are not detected.
|
||||
Extra entries or malformed dimensions are rejected.
|
||||
JSON-RPC uses `attachmentDimensions`, e.g. `["", "1080x1920"]`.
|
||||
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920`
|
||||
|
||||
*--view-once*::
|
||||
Send the message as a view once message.
|
||||
A conformant client will only allow the receiver to view the message once.
|
||||
|
||||
@ -112,6 +112,9 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
subparser.addArgument("--voice-note")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
|
||||
subparser.addArgument("--attachment-dimensions")
|
||||
.nargs("*")
|
||||
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -175,6 +178,9 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
|
||||
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
|
||||
|
||||
final var attachmentDimensions = parseAttachmentDimensions(ns.getList("attachment-dimensions"),
|
||||
attachments.size());
|
||||
|
||||
final var selfNumber = m.getSelfNumber();
|
||||
|
||||
final var mentionStrings = ns.<String>getList("mention");
|
||||
@ -249,6 +255,7 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
attachmentDimensions,
|
||||
viewOnce,
|
||||
voiceNote,
|
||||
mentions,
|
||||
@ -280,6 +287,35 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
|
||||
List<String> values,
|
||||
int attachmentCount
|
||||
) throws UserErrorException {
|
||||
if (values == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (values.size() > attachmentCount) {
|
||||
throw new UserErrorException("More attachment dimensions than attachments");
|
||||
}
|
||||
final var dimensions = new ArrayList<Message.AttachmentDimensions>(values.size());
|
||||
for (final var value : values) {
|
||||
if ("".equals(value)) {
|
||||
dimensions.add(Message.AttachmentDimensions.UNKNOWN);
|
||||
continue;
|
||||
}
|
||||
if (value == null || !value.matches("[1-9][0-9]*x[1-9][0-9]*")) {
|
||||
throw new UserErrorException("Attachment dimensions must be positive WIDTHxHEIGHT or an empty string");
|
||||
}
|
||||
final var parts = value.split("x");
|
||||
try {
|
||||
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new UserErrorException("Attachment dimensions exceed the supported integer range");
|
||||
}
|
||||
}
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
private List<Message.Mention> parseMentions(
|
||||
final String selfNumber,
|
||||
final List<String> mentionStrings
|
||||
|
||||
@ -242,6 +242,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
@ -409,6 +410,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
@ -456,6 +458,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
package org.asamk.signal.commands;
|
||||
|
||||
import net.sourceforge.argparse4j.ArgumentParsers;
|
||||
import net.sourceforge.argparse4j.DefaultSettings;
|
||||
import net.sourceforge.argparse4j.inf.Namespace;
|
||||
|
||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.Message;
|
||||
import org.asamk.signal.manager.api.SendMessageResults;
|
||||
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.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SendCommandParsingTest {
|
||||
|
||||
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
|
||||
|
||||
@Test
|
||||
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
|
||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
||||
.includeArgumentNamesAsKeysInResult(true).build();
|
||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
||||
final var message = send(parser.parseArgs(new String[]{"send", "--note-to-self",
|
||||
"-a", "photo.jpg", "portrait.mp4", "other.mp4", "--attachment-dimensions", "", "1080x1920"}));
|
||||
|
||||
assertEquals(ATTACHMENTS, message.attachments());
|
||||
assertEquals(List.of(Message.AttachmentDimensions.UNKNOWN, new Message.AttachmentDimensions(1080, 1920)),
|
||||
message.attachmentDimensions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
|
||||
final var message = send(namespace(List.of("", "1920x1080")));
|
||||
assertEquals(ATTACHMENTS, message.attachments());
|
||||
assertEquals(List.of(Message.AttachmentDimensions.UNKNOWN, new Message.AttachmentDimensions(1920, 1080)),
|
||||
message.attachmentDimensions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void omittedDimensionsKeepExistingBehavior() throws Exception {
|
||||
assertEquals(List.of(), send(new JsonRpcNamespace(Map.of("noteToSelf", true,
|
||||
"attachments", ATTACHMENTS))).attachmentDimensions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedOrExcessDimensionsBeforeSending() {
|
||||
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920", "2147483648x1")) {
|
||||
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
|
||||
}
|
||||
assertThrows(UserErrorException.class, () -> send(namespace(List.of("", "", "", "1x1"))));
|
||||
}
|
||||
|
||||
private JsonRpcNamespace namespace(List<String> dimensions) {
|
||||
return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
|
||||
"attachmentDimensions", dimensions));
|
||||
}
|
||||
|
||||
private Message send(Namespace namespace) throws Exception {
|
||||
final var captured = new AtomicReference<Message>();
|
||||
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")) {
|
||||
captured.set((Message) args[0]);
|
||||
return new SendMessageResults(1, Map.of());
|
||||
}
|
||||
return method.invoke(delegate, args);
|
||||
});
|
||||
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {});
|
||||
return captured.get();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user