diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 00000000..815811e6 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,64 @@ +# Task 1 Report: Core Library Layer — Group Story Support + +## Changes + +### `lib/src/main/java/org/asamk/signal/manager/Manager.java` +- `sendStory(String, boolean)` → `sendStory(String attachment, boolean allowsReplies, Optional groupId)`. +- Throws clause extended with `GroupNotFoundException, NotAGroupMemberException`. +- All required imports (`Optional`, `GroupId`, `GroupNotFoundException`, `NotAGroupMemberException`) were already present in the file, so no import changes were needed. +- Updated the javadoc above the method to describe the new `groupId` parameter. + +### `lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java` +- `sendStory()` now takes the third `Optional groupId` parameter and throws `GroupNotFoundException, NotAGroupMemberException` in addition to the existing exceptions. +- MIME validation runs first (shared by both paths); if `groupId.isPresent()`, control is handed off to a new private `sendGroupStory(String attachment, boolean allowsReplies, GroupId groupId)` method before any attachment upload happens (fail-fast). +- The pre-existing "My Story" branch (`groupId.isEmpty()`) is untouched — same code, same order of operations. +- New private `sendGroupStory` method: + 1. Resolves the group via `context.getGroupHelper().getGroup(groupId)`; throws `GroupNotFoundException` if null. + 2. Checks `groupInfo.isMember(account.getSelfRecipientId())`; throws `NotAGroupMemberException` if false. + 3. Requires a V2 group via pattern-match cast to `GroupInfoV2`; throws `IOException("Stories are only supported for V2 groups")` otherwise. + 4. Uploads the attachment, builds a `SignalServiceGroupV2` context (master key + revision, revision 0 if `getGroup()` is null), builds the `SignalServiceStoryMessage.forFileAttachment(...)` with the group context. + 5. Uses `getNextMessageTimestamp()` for the timestamp (not `System.currentTimeMillis()`). + 6. Calls `context.getSendHelper().sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies)`. + 7. Builds sync transcript recipients using `groupInfoV2.getDistributionId().asUuid().toString()` (not `DistributionId.MY_STORY`). + 8. Sends the sync message via `dependencies.getMessageSender().sendStorySyncMessage(...)`, wrapping `UntrustedIdentityException` in `IOException`, same as the My Story path. + 9. Returns `SendMessageResults` built the same way as the My Story path. +- Added imports: `org.asamk.signal.manager.storage.groups.GroupInfoV2` and `org.whispersystems.signalservice.api.messages.SignalServiceGroupV2`. + +### `lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java` +- New public method `sendGroupStoryMessage(SignalServiceStoryMessage storyMessage, long timestamp, GroupInfoV2 groupInfo, boolean allowsReplies) throws IOException`, inserted right after the existing `sendStoryMessage` (My Story) method. +- Flow follows the brief exactly: + 1. `groupInfo.getMembersWithout(account.getSelfRecipientId())` for recipients, converted to `List.copyOf(...)`. + 2. Resolves addresses via `context.getRecipientHelper()::resolveSignalServiceAddress` and unidentified access via `context.getUnidentifiedAccessHelper().getAccessFor(recipientIds)`. + 3. Calls the existing private `getGroupSendEndorsements(groupInfo)`; throws `IOException("Group send endorsements unavailable; try again after group state refreshes")` if the result is null. + 4. Derives `GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey())` and fetches the sender certificate via `context.getUnidentifiedAccessHelper().getSenderCertificateFor(null)`. + 5. Builds `GroupSendEndorsements` with an `ACI`-keyed map derived from the resolved addresses. + 6. Builds `SignalServiceStoryMessageRecipient`s using `groupInfo.getDistributionId().asUuid().toString()`. + 7. Calls `messageSender.sendGroupStory(groupInfo.getDistributionId(), Optional.of(groupInfo.getMasterKey().serialize()), addresses, unidentifiedAccesses, groupSendEndorsements, false, storyMessage, timestamp, storyMessageRecipients, null)`, catching `UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException` and rethrowing as `IOException`, mirroring `sendStoryMessage`'s existing catch clause. + 8. Calls `handleSendMessageResult(r)` for each result and returns the list. +- No new imports were required — `GroupInfoV2`, `GroupSecretParams`, `ACI`, `GroupSendEndorsements`, and `UnidentifiedAccess` were already imported in this file (used by the existing group-message sending code). + +## Build output + +``` +$ export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home +$ ./gradlew :lib:compileJava 2>&1 | tail -10 +> Task :buildSrc:checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :buildSrc:compileKotlin UP-TO-DATE +> Task :buildSrc:compileJava NO-SOURCE +> Task :buildSrc:compileGroovy NO-SOURCE +> Task :buildSrc:pluginDescriptors UP-TO-DATE +> Task :buildSrc:processResources UP-TO-DATE +> Task :buildSrc:classes UP-TO-DATE +> Task :buildSrc:jar UP-TO-DATE +> Task :libsignal-cli:compileJava + +BUILD SUCCESSFUL in 1s +``` + +Note: the Gradle project for the `lib/` source tree is actually named `:libsignal-cli` (see `settings.gradle.kts`: `project(":libsignal-cli").projectDir = file("lib")`). Gradle's task-selector prefix matching resolved `:lib:compileJava` to `:libsignal-cli:compileJava`, which compiled successfully — confirming the lib module (including these changes) compiles cleanly on its own, without the command layer. + +As expected/documented in the brief, the command layer (`SendStoryCommand`, `DbusManagerImpl`, `StubManager`) was intentionally left untouched and will fail to compile against the new 3-parameter `sendStory` signature until Tasks 2/3 update those call sites. This was not exercised here since only `:lib:compileJava` was run. + +## Concerns + +None. The implementation follows the brief's step-by-step spec verbatim, reuses the exact patterns already present in the "My Story" code paths (`sendStory` in ManagerImpl, `sendStoryMessage`/`getGroupSendEndorsements`/`GroupSendEndorsements` construction in SendHelper), and required no new imports in SendHelper since all needed types were already imported for the pre-existing sender-key group-message code path. diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 4c625e79..791c9825 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -214,12 +214,17 @@ public interface Manager extends Closeable { ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException; /** - * Post a file attachment story to "My Story". + * Post a file attachment story to "My Story" or to a group. * * @param attachment path to the file to upload and post as a story * @param allowsReplies whether other users are allowed to reply to this story + * @param groupId if present, post the story to this group instead of "My Story" */ - SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException; + SendMessageResults sendStory( + String attachment, + boolean allowsReplies, + Optional groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException; SendMessageResults sendRemoteDeleteMessage( long targetSentTimestamp, 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 261ce228..4ec884a6 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 @@ -379,6 +379,67 @@ public class SendHelper { return results; } + /** + * Send a story message (file attachment) to a group. + */ + public List sendGroupStoryMessage( + SignalServiceStoryMessage storyMessage, + long timestamp, + GroupInfoV2 groupInfo, + boolean allowsReplies + ) throws IOException { + final var messageSender = dependencies.getMessageSender(); + + final var recipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId()); + final var recipientIdList = List.copyOf(recipientIds); + final var addressesMap = recipientIdList.stream() + .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); + final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds); + + final var groupSendEndorsementsResult = getGroupSendEndorsements(groupInfo); + if (groupSendEndorsementsResult == null) { + throw new IOException("Group send endorsements unavailable; try again after group state refreshes"); + } + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey()); + final var senderCertificate = context.getUnidentifiedAccessHelper().getSenderCertificateFor(null); + final var groupSendEndorsements = new GroupSendEndorsements(groupSendEndorsementsResult.first(), + recipientIdList.stream() + .collect(Collectors.toMap(id -> (ACI) addressesMap.get(id).getServiceId(), + groupSendEndorsementsResult.second()::get)), + senderCertificate, + groupSecretParams); + + final var addresses = recipientIdList.stream().map(addressesMap::get).toList(); + final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList(); + final var storyMessageRecipients = recipientIdList.stream() + .map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id), + List.of(groupInfo.getDistributionId().asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + + final List results; + try { + results = messageSender.sendGroupStory(groupInfo.getDistributionId(), + Optional.of(groupInfo.getMasterKey().serialize()), + addresses, + unidentifiedAccesses, + groupSendEndorsements, + false, + storyMessage, + timestamp, + storyMessageRecipients, + null); + } catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) { + throw new IOException(e); + } + + for (var r : results) { + handleSendMessageResult(r); + } + + return results; + } + private List sendAsGroupMessage( final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g, 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 3b812c5e..5f1d90fd 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 @@ -83,6 +83,7 @@ import org.asamk.signal.manager.storage.AttachmentStore; import org.asamk.signal.manager.storage.AvatarStore; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.groups.GroupInfo; +import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientId; @@ -107,6 +108,7 @@ import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; @@ -833,8 +835,9 @@ public class ManagerImpl implements Manager { @Override public SendMessageResults sendStory( String attachment, - boolean allowsReplies - ) throws IOException, AttachmentInvalidException { + boolean allowsReplies, + Optional groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException { final var file = new File(attachment); final var mimeType = MimeUtils.getFileMimeType(file); if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) { @@ -842,6 +845,10 @@ public class ManagerImpl implements Manager { new IOException("Stories only support image and video attachments")); } + if (groupId.isPresent()) { + return sendGroupStory(attachment, allowsReplies, groupId.get()); + } + final var recipients = account.getRecipientStore() .getRecipients(true, Optional.of(false), Set.of(), Optional.empty()); final var recipientIds = recipients.stream() @@ -886,6 +893,57 @@ public class ManagerImpl implements Manager { return new SendMessageResults(timestamp, results); } + private SendMessageResults sendGroupStory( + String attachment, + boolean allowsReplies, + GroupId groupId + ) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException { + final var groupInfo = context.getGroupHelper().getGroup(groupId); + if (groupInfo == null) { + throw new GroupNotFoundException(groupId); + } + if (!groupInfo.isMember(account.getSelfRecipientId())) { + throw new NotAGroupMemberException(groupId, groupInfo.getTitle()); + } + if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { + throw new IOException("Stories are only supported for V2 groups"); + } + + final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); + final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) + .withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision) + .build(); + final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(), + groupContext, + uploadedAttachment, + allowsReplies, + List.of()); + final var timestamp = getNextMessageTimestamp(); + + final var sendResults = context.getSendHelper() + .sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies); + + final var storyMessageRecipients = sendResults.stream() + .filter(org.whispersystems.signalservice.api.messages.SendMessageResult::isSuccess) + .map(r -> new SignalServiceStoryMessageRecipient(r.getAddress(), + List.of(groupInfoV2.getDistributionId().asUuid().toString()), + allowsReplies)) + .collect(Collectors.toSet()); + try { + dependencies.getMessageSender().sendStorySyncMessage(storyMessage, timestamp, false, storyMessageRecipients); + } catch (UntrustedIdentityException e) { + throw new IOException(e); + } + + final var results = new HashMap>(); + for (final var sendResult : sendResults) { + final var result = toSendMessageResult(sendResult); + results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result)); + } + + return new SendMessageResults(timestamp, results); + } + private void applyMessage( final SignalServiceDataMessage.Builder messageBuilder, final Message message