diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4794af..b091909f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- `send --attachment-dimensions` to supply displayed attachment dimensions for correctly sized video placeholders +- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded ## [0.14.8] - 2026-09-10 diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Message.java b/lib/src/main/java/org/asamk/signal/manager/api/Message.java index a37ced94..ccb7cba8 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Message.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Message.java @@ -7,6 +7,7 @@ public record Message( String messageText, List attachments, List attachmentDimensions, + List attachmentBlurHashes, boolean viewOnce, boolean voiceNote, List mentions, @@ -18,16 +19,7 @@ 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 AttachmentDimensions(int width, int height) {} public record Mention(RecipientIdentifier.Single recipient, int start, int length) {} 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 0968aa48..85ea14a4 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 @@ -54,9 +54,10 @@ public class AttachmentHelper { public List uploadAttachments( final List attachments, final List dimensions, + final List blurHashes, boolean voiceNote ) throws AttachmentInvalidException, IOException { - final var attachmentStreams = createAttachmentStreams(attachments, dimensions, voiceNote); + final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote); try { // Upload attachments here, so we only upload once even for multiple recipients @@ -72,9 +73,14 @@ public class AttachmentHelper { } } + public List uploadAttachments(final List attachments) throws AttachmentInvalidException, IOException { + return uploadAttachments(attachments, List.of(), List.of(), false); + } + private List createAttachmentStreams( List attachments, List dimensions, + List blurHashes, boolean voiceNote ) throws AttachmentInvalidException, IOException { if (attachments == null) { @@ -82,8 +88,9 @@ public class AttachmentHelper { } final var signalServiceAttachments = new ArrayList(attachments.size()); 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)); + 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)); } return signalServiceAttachments; } @@ -91,6 +98,7 @@ public class AttachmentHelper { private SignalServiceAttachmentStream getAttachmentStream( final String attachment, final AttachmentDimensions dimensions, + final String blurHash, final boolean voiceNote ) throws AttachmentInvalidException { try { @@ -117,6 +125,7 @@ public class AttachmentHelper { streamDetailsAndFileName.second(), voiceNote, dimensions, + blurHash, uploadSpec); } catch (IOException e) { throw new AttachmentInvalidException(attachment, e); @@ -131,7 +140,7 @@ public class AttachmentHelper { } public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException { - final var attachmentStream = getAttachmentStream(attachment, AttachmentDimensions.UNKNOWN, false); + final var attachmentStream = getAttachmentStream(attachment, null, null, false); return uploadAttachment(attachmentStream); } 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 6a4ec67c..1b0ed529 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 @@ -970,7 +970,10 @@ public class ManagerImpl implements Manager { } if (!message.attachments().isEmpty()) { final var uploadedAttachments = context.getAttachmentHelper() - .uploadAttachments(message.attachments(), message.attachmentDimensions(), message.voiceNote()); + .uploadAttachments(message.attachments(), + message.attachmentDimensions(), + message.attachmentBlurHashes(), + message.voiceNote()); if (!additionalAttachments.isEmpty()) { additionalAttachments.addAll(uploadedAttachments); messageBuilder.withAttachments(additionalAttachments); diff --git a/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java index b0211bec..d162dc7c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java @@ -29,6 +29,7 @@ public class AttachmentUtils { Optional name, boolean voiceNote, AttachmentDimensions dimensions, + String blurHash, ResumableUploadSpec resumableUploadSpec ) throws ResumeLocationInvalidException, IOException { final var uploadTimestamp = System.currentTimeMillis(); @@ -39,8 +40,9 @@ public class AttachmentUtils { .withLength(streamDetails.getLength()) .withFileName(name.orElse(null)) .withVoiceNote(voiceNote) - .withWidth(dimensions.width() > 0 ? dimensions.width() : probedStream.width()) - .withHeight(dimensions.height() > 0 ? dimensions.height() : probedStream.height()) + .withBlurHash(blurHash) + .withWidth(dimensions != null ? dimensions.width() : probedStream.width()) + .withHeight(dimensions != null ? dimensions.height() : probedStream.height()) .withUploadTimestamp(uploadTimestamp) .withResumableUploadSpec(resumableUploadSpec) .withUuid(UUID.randomUUID()) @@ -52,7 +54,7 @@ public class AttachmentUtils { Optional name, ResumableUploadSpec resumableUploadSpec ) throws ResumeLocationInvalidException, IOException { - return createAttachmentStream(streamDetails, name, false, AttachmentDimensions.UNKNOWN, resumableUploadSpec); + return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec); } /** diff --git a/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java b/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java index 332debab..7e5d993c 100644 --- a/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java @@ -45,15 +45,16 @@ class AttachmentUtilsTest { } @Test - public void createAttachmentStream_setsSuppliedVideoDimensionsWithoutReadingVideo() throws Exception { + public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception { final var bytes = "opaque video bytes".getBytes(); + final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; 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); + Optional.of("clip.mp4"), false, new AttachmentDimensions(1080, 1920), blurHash, null); assertEquals(1080, attachment.getWidth()); assertEquals(1920, attachment.getHeight()); - assertEquals(Optional.empty(), attachment.getBlurHash()); + assertEquals(Optional.of(blurHash), attachment.getBlurHash()); assertArrayEquals(bytes, attachment.getInputStream().readAllBytes()); } diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 40606d4a..26fac479 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -345,11 +345,13 @@ Additionally a file name can be added: e.g.: `data:;filename=;base64,` *--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` +Specify the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`. +Use an empty string to skip one; image dimensions are otherwise detected automatically. + +*--attachment-blurhash* [BLURHASH [BLURHASH ...]]:: +Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading. +Use an empty string to skip one. +e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'` *--view-once*:: Send the message as a view once message. diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 31afb12e..dc6be9a4 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -36,6 +36,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult; public class SendCommand implements JsonRpcLocalCommand { private static final Logger logger = LoggerFactory.getLogger(SendCommand.class); + private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; @Override public String getName() { @@ -115,6 +116,10 @@ public class SendCommand implements JsonRpcLocalCommand { subparser.addArgument("--attachment-dimensions") .nargs("*") .help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one."); + subparser.addArgument("--attachment-blurhash") + .nargs("*") + .help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show " + + "while it's downloading. Use an empty string to skip one."); } @Override @@ -178,8 +183,12 @@ 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 dimensionStrings = ns.getList("attachment-dimensions"); + final var attachmentDimensions = dimensionStrings == null + ? List.of() + : parseAttachmentDimensions(dimensionStrings); + final var blurHashes = ns.getList("attachment-blurhash"); + final var attachmentBlurHashes = blurHashes == null ? List.of() : parseAttachmentBlurHashes(blurHashes); final var selfNumber = m.getSelfNumber(); @@ -256,6 +265,7 @@ public class SendCommand implements JsonRpcLocalCommand { final var message = new Message(messageText, attachments, attachmentDimensions, + attachmentBlurHashes, viewOnce, voiceNote, mentions, @@ -288,34 +298,45 @@ public class SendCommand implements JsonRpcLocalCommand { } private List parseAttachmentDimensions( - List values, - int attachmentCount + final List dimensionStrings ) 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(values.size()); - for (final var value : values) { - if ("".equals(value)) { - dimensions.add(Message.AttachmentDimensions.UNKNOWN); + final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)"); + final var dimensions = new ArrayList(); + for (final var dimension : dimensionStrings) { + if (dimension.isEmpty()) { + dimensions.add(null); 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"); + final var matcher = dimensionPattern.matcher(dimension); + if (!matcher.matches()) { + throw new UserErrorException("Invalid attachment dimensions syntax (" + + dimension + + ") expected 'WIDTHxHEIGHT'"); } + dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)))); } return dimensions; } + private List parseAttachmentBlurHashes(final List blurHashes) throws UserErrorException { + for (final var blurHash : blurHashes) { + if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) { + throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")"); + } + } + return blurHashes; + } + + // Same check the BlurHash decoders make: the first digit fixes the length. + private static boolean isValidBlurHash(final String blurHash) { + if (blurHash.length() < 6) { + return false; + } + final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0)); + return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1); + } + private List parseMentions( final String selfNumber, final List mentionStrings diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 6f520572..30d7e4a0 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -243,6 +243,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { final var message = new Message(messageText, attachments, List.of(), + List.of(), false, false, List.of(), @@ -411,6 +412,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { final var message = new Message(messageText, attachments, List.of(), + List.of(), false, false, List.of(), @@ -459,6 +461,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { final var message = new Message(messageText, attachments, List.of(), + List.of(), false, false, List.of(), diff --git a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java index 3bfee70c..f56b00e2 100644 --- a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java +++ b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java @@ -13,6 +13,7 @@ import org.asamk.signal.testutil.ManagerMock; import org.junit.jupiter.api.Test; import java.lang.reflect.Proxy; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -23,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class SendCommandParsingTest { private static final List ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4"); + private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; @Test void cliPreservesEmptyAndMissingDimensionPositions() throws Exception { @@ -33,7 +35,7 @@ class SendCommandParsingTest { "-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)), + assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions()); } @@ -41,22 +43,56 @@ class SendCommandParsingTest { 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)), + assertEquals(Arrays.asList(null, 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()); + void cliPreservesBlurHashPositions() 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-blurhash", "", BLUR_HASH})); + + assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes()); } @Test - void rejectsMalformedOrExcessDimensionsBeforeSending() { - for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920", "2147483648x1")) { + void jsonRpcPassesBlurHashesWithDimensions() throws Exception { + final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS, + "attachmentDimensions", List.of("", "1920x1080"), "attachmentBlurhash", List.of("", BLUR_HASH)))); + + assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), + message.attachmentDimensions()); + assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes()); + } + + @Test + void omittedDimensionsKeepExistingBehavior() throws Exception { + final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS))); + assertEquals(List.of(), message.attachmentDimensions()); + assertEquals(List.of(), message.attachmentBlurHashes()); + } + + @Test + void rejectsMalformedDimensionsBeforeSending() { + for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) { assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value); } - assertThrows(UserErrorException.class, () -> send(namespace(List.of("", "", "", "1x1")))); + } + + @Test + void rejectsMalformedBlurHashesBeforeSending() { + // Wrong length for its size digit, an unknown size digit, too short. + for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) { + assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value); + } + } + + private JsonRpcNamespace blurHashNamespace(List blurHashes) { + return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS, + "attachmentBlurhash", blurHashes)); } private JsonRpcNamespace namespace(List dimensions) {