]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/Manager.java
Implement jsonRpc command
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / Manager.java
index 0f75f9093806e4e2f3470252c699234736c0b305..98b02c7f4e3cd45af04eaa06642e7bb85e06f415 100644 (file)
@@ -17,6 +17,7 @@
 package org.asamk.signal.manager;
 
 import org.asamk.signal.manager.api.Device;
+import org.asamk.signal.manager.api.TypingAction;
 import org.asamk.signal.manager.config.ServiceConfig;
 import org.asamk.signal.manager.config.ServiceEnvironment;
 import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
@@ -33,6 +34,9 @@ import org.asamk.signal.manager.helper.GroupV2Helper;
 import org.asamk.signal.manager.helper.PinHelper;
 import org.asamk.signal.manager.helper.ProfileHelper;
 import org.asamk.signal.manager.helper.UnidentifiedAccessHelper;
+import org.asamk.signal.manager.jobs.Context;
+import org.asamk.signal.manager.jobs.Job;
+import org.asamk.signal.manager.jobs.RetrieveStickerPackJob;
 import org.asamk.signal.manager.storage.SignalAccount;
 import org.asamk.signal.manager.storage.groups.GroupInfo;
 import org.asamk.signal.manager.storage.groups.GroupInfoV1;
@@ -87,6 +91,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
 import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
 import org.whispersystems.signalservice.api.SignalServiceMessageSender;
 import org.whispersystems.signalservice.api.SignalSessionLock;
+import org.whispersystems.signalservice.api.crypto.ContentHint;
 import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
 import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
 import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
@@ -105,6 +110,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
 import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
 import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
 import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
 import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage;
 import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
 import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact;
@@ -121,6 +127,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage
 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.push.exceptions.ConflictException;
 import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
 import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
 import org.whispersystems.signalservice.api.util.DeviceNameUtil;
@@ -198,6 +205,7 @@ public class Manager implements Closeable {
     private final PinHelper pinHelper;
     private final AvatarStore avatarStore;
     private final AttachmentStore attachmentStore;
+    private final StickerPackStore stickerPackStore;
     private final SignalSessionLock sessionLock = new SignalSessionLock() {
         private final ReentrantLock LEGACY_LOCK = new ReentrantLock();
 
@@ -271,6 +279,7 @@ public class Manager implements Closeable {
                 this::resolveSignalServiceAddress);
         this.avatarStore = new AvatarStore(pathConfig.getAvatarsPath());
         this.attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath());
+        this.stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath());
     }
 
     public String getUsername() {
@@ -609,21 +618,27 @@ public class Manager implements Closeable {
             return null;
         }
 
+        profile = decryptProfileIfKeyKnown(recipientId, encryptedProfile);
+        account.getProfileStore().storeProfile(recipientId, profile);
+
+        return profile;
+    }
+
+    private Profile decryptProfileIfKeyKnown(
+            final RecipientId recipientId, final SignalServiceProfile encryptedProfile
+    ) {
         var profileKey = account.getProfileStore().getProfileKey(recipientId);
         if (profileKey == null) {
-            profile = new Profile(System.currentTimeMillis(),
+            return new Profile(System.currentTimeMillis(),
                     null,
                     null,
                     null,
                     null,
                     ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null),
                     ProfileUtils.getCapabilities(encryptedProfile));
-        } else {
-            profile = decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile);
         }
-        account.getProfileStore().storeProfile(recipientId, profile);
 
-        return profile;
+        return decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile);
     }
 
     private SignalServiceProfile retrieveEncryptedProfile(RecipientId recipientId) {
@@ -778,35 +793,55 @@ public class Manager implements Closeable {
     public Pair<Long, List<SendMessageResult>> sendQuitGroupMessage(
             GroupId groupId, Set<String> groupAdmins
     ) throws GroupNotFoundException, IOException, NotAGroupMemberException, InvalidNumberException, LastGroupAdminException {
-        SignalServiceDataMessage.Builder messageBuilder;
+        var group = getGroupForUpdating(groupId);
+        if (group instanceof GroupInfoV1) {
+            return quitGroupV1((GroupInfoV1) group);
+        }
 
-        final var g = getGroupForUpdating(groupId);
-        if (g instanceof GroupInfoV1) {
-            var groupInfoV1 = (GroupInfoV1) g;
-            var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT).withId(groupId.serialize()).build();
-            messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
-            groupInfoV1.removeMember(account.getSelfRecipientId());
-            account.getGroupStore().updateGroup(groupInfoV1);
-        } else {
-            final var groupInfoV2 = (GroupInfoV2) g;
-            final var currentAdmins = g.getAdminMembers();
-            final var newAdmins = getSignalServiceAddresses(groupAdmins);
-            newAdmins.removeAll(currentAdmins);
-            newAdmins.retainAll(g.getMembers());
-            if (currentAdmins.contains(getSelfRecipientId())
-                    && currentAdmins.size() == 1
-                    && g.getMembers().size() > 1
-                    && newAdmins.size() == 0) {
-                // Last admin can't leave the group, unless she's also the last member
-                throw new LastGroupAdminException(g.getGroupId(), g.getTitle());
-            }
-            final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins);
-            groupInfoV2.setGroup(groupGroupChangePair.first(), this::resolveRecipient);
-            messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
-            account.getGroupStore().updateGroup(groupInfoV2);
+        final var newAdmins = getSignalServiceAddresses(groupAdmins);
+        try {
+            return quitGroupV2((GroupInfoV2) group, newAdmins);
+        } catch (ConflictException e) {
+            // Detected conflicting update, refreshing group and trying again
+            group = getGroup(groupId, true);
+            return quitGroupV2((GroupInfoV2) group, newAdmins);
         }
+    }
 
-        return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfRecipientId()));
+    private Pair<Long, List<SendMessageResult>> quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
+        var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
+                .withId(groupInfoV1.getGroupId().serialize())
+                .build();
+
+        var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
+        groupInfoV1.removeMember(account.getSelfRecipientId());
+        account.getGroupStore().updateGroup(groupInfoV1);
+        return sendMessage(messageBuilder, groupInfoV1.getMembersWithout(account.getSelfRecipientId()));
+    }
+
+    private Pair<Long, List<SendMessageResult>> quitGroupV2(
+            final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
+    ) throws LastGroupAdminException, IOException {
+        final var currentAdmins = groupInfoV2.getAdminMembers();
+        newAdmins.removeAll(currentAdmins);
+        newAdmins.retainAll(groupInfoV2.getMembers());
+        if (currentAdmins.contains(getSelfRecipientId())
+                && currentAdmins.size() == 1
+                && groupInfoV2.getMembers().size() > 1
+                && newAdmins.size() == 0) {
+            // Last admin can't leave the group, unless she's also the last member
+            throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
+        }
+        final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins);
+        groupInfoV2.setGroup(groupGroupChangePair.first(), this::resolveRecipient);
+        var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
+        account.getGroupStore().updateGroup(groupInfoV2);
+        return sendMessage(messageBuilder, groupInfoV2.getMembersWithout(account.getSelfRecipientId()));
+    }
+
+    public void deleteGroup(GroupId groupId) throws IOException {
+        account.getGroupStore().deleteGroup(groupId);
+        avatarStore.deleteGroupAvatar(groupId);
     }
 
     public Pair<GroupId, List<SendMessageResult>> createGroup(
@@ -900,19 +935,37 @@ public class Manager implements Closeable {
         var group = getGroupForUpdating(groupId);
 
         if (group instanceof GroupInfoV2) {
-            return updateGroupV2((GroupInfoV2) group,
-                    name,
-                    description,
-                    members,
-                    removeMembers,
-                    admins,
-                    removeAdmins,
-                    resetGroupLink,
-                    groupLinkState,
-                    addMemberPermission,
-                    editDetailsPermission,
-                    avatarFile,
-                    expirationTimer);
+            try {
+                return updateGroupV2((GroupInfoV2) group,
+                        name,
+                        description,
+                        members,
+                        removeMembers,
+                        admins,
+                        removeAdmins,
+                        resetGroupLink,
+                        groupLinkState,
+                        addMemberPermission,
+                        editDetailsPermission,
+                        avatarFile,
+                        expirationTimer);
+            } catch (ConflictException e) {
+                // Detected conflicting update, refreshing group and trying again
+                group = getGroup(groupId, true);
+                return updateGroupV2((GroupInfoV2) group,
+                        name,
+                        description,
+                        members,
+                        removeMembers,
+                        admins,
+                        removeAdmins,
+                        resetGroupLink,
+                        groupLinkState,
+                        addMemberPermission,
+                        editDetailsPermission,
+                        avatarFile,
+                        expirationTimer);
+            }
         }
 
         final var gv1 = (GroupInfoV1) group;
@@ -1392,18 +1445,20 @@ public class Manager implements Closeable {
         var messageSender = createMessageSender();
 
         var packKey = KeyUtils.createStickerUploadKey();
-        var packId = messageSender.uploadStickerManifest(manifest, packKey);
+        var packIdString = messageSender.uploadStickerManifest(manifest, packKey);
+        var packId = StickerPackId.deserialize(Hex.fromStringCondensed(packIdString));
 
-        var sticker = new Sticker(StickerPackId.deserialize(Hex.fromStringCondensed(packId)), packKey);
+        var sticker = new Sticker(packId, packKey);
         account.getStickerStore().updateSticker(sticker);
 
         try {
             return new URI("https",
                     "signal.art",
                     "/addstickers/",
-                    "pack_id=" + URLEncoder.encode(packId, StandardCharsets.UTF_8) + "&pack_key=" + URLEncoder.encode(
-                            Hex.toStringCondensed(packKey),
-                            StandardCharsets.UTF_8)).toString();
+                    "pack_id="
+                            + URLEncoder.encode(Hex.toStringCondensed(packId.serialize()), StandardCharsets.UTF_8)
+                            + "&pack_key="
+                            + URLEncoder.encode(Hex.toStringCondensed(packKey), StandardCharsets.UTF_8)).toString();
         } catch (URISyntaxException e) {
             throw new AssertionError(e);
         }
@@ -1495,7 +1550,7 @@ public class Manager implements Closeable {
 
     private void sendSyncMessage(SignalServiceSyncMessage message) throws IOException, UntrustedIdentityException {
         var messageSender = createMessageSender();
-        messageSender.sendMessage(message, unidentifiedAccessHelper.getAccessForSync());
+        messageSender.sendSyncMessage(message, unidentifiedAccessHelper.getAccessForSync());
     }
 
     private Set<RecipientId> getSignalServiceAddresses(Collection<String> numbers) throws InvalidNumberException {
@@ -1557,6 +1612,40 @@ public class Manager implements Closeable {
         }
     }
 
+    public void sendTypingMessage(
+            TypingAction action, Set<String> recipients
+    ) throws IOException, UntrustedIdentityException, InvalidNumberException {
+        sendTypingMessageInternal(action, getSignalServiceAddresses(recipients));
+    }
+
+    private void sendTypingMessageInternal(
+            TypingAction action, Set<RecipientId> recipientIds
+    ) throws IOException, UntrustedIdentityException {
+        final var timestamp = System.currentTimeMillis();
+        var message = new SignalServiceTypingMessage(action.toSignalService(), timestamp, Optional.absent());
+        var messageSender = createMessageSender();
+        for (var recipientId : recipientIds) {
+            final var address = resolveSignalServiceAddress(recipientId);
+            messageSender.sendTyping(address, unidentifiedAccessHelper.getAccessFor(recipientId), message);
+        }
+    }
+
+    public void sendGroupTypingMessage(
+            TypingAction action, GroupId groupId
+    ) throws IOException, NotAGroupMemberException, GroupNotFoundException {
+        final var timestamp = System.currentTimeMillis();
+        final var g = getGroupForSending(groupId);
+        final var message = new SignalServiceTypingMessage(action.toSignalService(),
+                timestamp,
+                Optional.of(groupId.serialize()));
+        final var messageSender = createMessageSender();
+        final var recipientIdList = new ArrayList<>(g.getMembersWithout(account.getSelfRecipientId()));
+        final var addresses = recipientIdList.stream()
+                .map(this::resolveSignalServiceAddress)
+                .collect(Collectors.toList());
+        messageSender.sendTyping(addresses, unidentifiedAccessHelper.getAccessFor(recipientIdList), message, null);
+    }
+
     private Pair<Long, List<SendMessageResult>> sendMessage(
             SignalServiceDataMessage.Builder messageBuilder, Set<RecipientId> recipientIds
     ) throws IOException {
@@ -1575,9 +1664,10 @@ public class Manager implements Closeable {
                     final var addresses = recipientIdList.stream()
                             .map(this::resolveSignalServiceAddress)
                             .collect(Collectors.toList());
-                    var result = messageSender.sendMessage(addresses,
+                    var result = messageSender.sendDataMessage(addresses,
                             unidentifiedAccessHelper.getAccessFor(recipientIdList),
                             isRecipientUpdate,
+                            ContentHint.DEFAULT,
                             message);
 
                     for (var r : result) {
@@ -1652,7 +1742,7 @@ public class Manager implements Closeable {
 
         try {
             var startTime = System.currentTimeMillis();
-            messageSender.sendMessage(syncMessage, unidentifiedAccess);
+            messageSender.sendSyncMessage(syncMessage, unidentifiedAccess);
             return SendMessageResult.success(recipient,
                     unidentifiedAccess.isPresent(),
                     false,
@@ -1670,11 +1760,15 @@ public class Manager implements Closeable {
         final var address = resolveSignalServiceAddress(recipientId);
         try {
             try {
-                return messageSender.sendMessage(address, unidentifiedAccessHelper.getAccessFor(recipientId), message);
+                return messageSender.sendDataMessage(address,
+                        unidentifiedAccessHelper.getAccessFor(recipientId),
+                        ContentHint.DEFAULT,
+                        message);
             } catch (UnregisteredUserException e) {
                 final var newRecipientId = refreshRegisteredUser(recipientId);
-                return messageSender.sendMessage(resolveSignalServiceAddress(newRecipientId),
+                return messageSender.sendDataMessage(resolveSignalServiceAddress(newRecipientId),
                         unidentifiedAccessHelper.getAccessFor(newRecipientId),
+                        ContentHint.DEFAULT,
                         message);
             }
         } catch (UntrustedIdentityException e) {
@@ -1858,6 +1952,7 @@ public class Manager implements Closeable {
                 sticker = new Sticker(stickerPackId, messageSticker.getPackKey());
                 account.getStickerStore().updateSticker(sticker);
             }
+            enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey()));
         }
         return actions;
     }
@@ -1933,6 +2028,9 @@ public class Manager implements Closeable {
             try {
                 action.execute(this);
             } catch (Throwable e) {
+                if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                    Thread.currentThread().interrupt();
+                }
                 logger.warn("Message action failed.", e);
             }
         }
@@ -1979,7 +2077,7 @@ public class Manager implements Closeable {
             boolean returnOnTimeout,
             boolean ignoreAttachments,
             ReceiveMessageHandler handler
-    ) throws IOException {
+    ) throws IOException, InterruptedException {
         retryFailedReceivedMessages(handler, ignoreAttachments);
 
         Set<HandleAction> queuedActions = null;
@@ -1988,12 +2086,13 @@ public class Manager implements Closeable {
 
         var hasCaughtUpWithOldMessages = false;
 
-        while (true) {
+        while (!Thread.interrupted()) {
             SignalServiceEnvelope envelope;
             SignalServiceContent content = null;
             Exception exception = null;
             final CachedMessage[] cachedMessage = {null};
             account.setLastReceiveTimestamp(System.currentTimeMillis());
+            logger.debug("Checking for new message from server");
             try {
                 var result = messagePipe.readOrEmpty(timeout, unit, envelope1 -> {
                     final var recipientId = envelope1.hasSource()
@@ -2002,6 +2101,7 @@ public class Manager implements Closeable {
                     // store message on disk, before acknowledging receipt to the server
                     cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
                 });
+                logger.debug("New message received from server");
                 if (result.isPresent()) {
                     envelope = result.get();
                 } else {
@@ -2013,6 +2113,9 @@ public class Manager implements Closeable {
                             try {
                                 action.execute(this);
                             } catch (Throwable e) {
+                                if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                                    Thread.currentThread().interrupt();
+                                }
                                 logger.warn("Message action failed.", e);
                             }
                         }
@@ -2023,6 +2126,12 @@ public class Manager implements Closeable {
                     // Continue to wait another timeout for new messages
                     continue;
                 }
+            } catch (AssertionError e) {
+                if (e.getCause() instanceof InterruptedException) {
+                    throw (InterruptedException) e.getCause();
+                } else {
+                    throw e;
+                }
             } catch (TimeoutException e) {
                 if (returnOnTimeout) return;
                 continue;
@@ -2056,6 +2165,9 @@ public class Manager implements Closeable {
                         try {
                             action.execute(this);
                         } catch (Throwable e) {
+                            if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                                Thread.currentThread().interrupt();
+                            }
                             logger.warn("Message action failed.", e);
                         }
                     }
@@ -2221,7 +2333,16 @@ public class Manager implements Closeable {
                         try (var attachmentAsStream = retrieveAttachmentAsStream(groupsMessage.asPointer(), tmpFile)) {
                             var s = new DeviceGroupsInputStream(attachmentAsStream);
                             DeviceGroup g;
-                            while ((g = s.read()) != null) {
+                            while (true) {
+                                try {
+                                    g = s.read();
+                                } catch (IOException e) {
+                                    logger.warn("Sync groups contained invalid group, ignoring: {}", e.getMessage());
+                                    continue;
+                                }
+                                if (g == null) {
+                                    break;
+                                }
                                 var syncGroup = account.getGroupStore().getOrCreateGroupV1(GroupId.v1(g.getId()));
                                 if (syncGroup != null) {
                                     if (g.getName().isPresent()) {
@@ -2292,7 +2413,17 @@ public class Manager implements Closeable {
                                 .asPointer(), tmpFile)) {
                             var s = new DeviceContactsInputStream(attachmentAsStream);
                             DeviceContact c;
-                            while ((c = s.read()) != null) {
+                            while (true) {
+                                try {
+                                    c = s.read();
+                                } catch (IOException e) {
+                                    logger.warn("Sync contacts contained invalid contact, ignoring: {}",
+                                            e.getMessage());
+                                    continue;
+                                }
+                                if (c == null) {
+                                    break;
+                                }
                                 if (c.getAddress().matches(account.getSelfAddress()) && c.getProfileKey().isPresent()) {
                                     account.setProfileKey(c.getProfileKey().get());
                                 }
@@ -2359,16 +2490,23 @@ public class Manager implements Closeable {
                             continue;
                         }
                         final var stickerPackId = StickerPackId.deserialize(m.getPackId().get());
+                        final var installed = !m.getType().isPresent()
+                                || m.getType().get() == StickerPackOperationMessage.Type.INSTALL;
+
                         var sticker = account.getStickerStore().getSticker(stickerPackId);
-                        if (sticker == null) {
-                            if (!m.getPackKey().isPresent()) {
-                                continue;
+                        if (m.getPackKey().isPresent()) {
+                            if (sticker == null) {
+                                sticker = new Sticker(stickerPackId, m.getPackKey().get());
+                            }
+                            if (installed) {
+                                enqueueJob(new RetrieveStickerPackJob(stickerPackId, m.getPackKey().get()));
                             }
-                            sticker = new Sticker(stickerPackId, m.getPackKey().get());
                         }
-                        sticker.setInstalled(!m.getType().isPresent()
-                                || m.getType().get() == StickerPackOperationMessage.Type.INSTALL);
-                        account.getStickerStore().updateSticker(sticker);
+
+                        if (sticker != null) {
+                            sticker.setInstalled(installed);
+                            account.getStickerStore().updateSticker(sticker);
+                        }
                     }
                 }
                 if (syncMessage.getFetchType().isPresent()) {
@@ -2426,6 +2564,9 @@ public class Manager implements Closeable {
             avatarStore.storeProfileAvatar(address,
                     outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
         } catch (Throwable e) {
+            if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
             logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
         }
     }
@@ -2703,8 +2844,12 @@ public class Manager implements Closeable {
     }
 
     public GroupInfo getGroup(GroupId groupId) {
+        return getGroup(groupId, false);
+    }
+
+    public GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
         final var group = account.getGroupStore().getGroup(groupId);
-        if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
+        if (group instanceof GroupInfoV2 && (forceUpdate || ((GroupInfoV2) group).getGroup() == null)) {
             final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey());
             ((GroupInfoV2) group).setGroup(groupV2Helper.getDecryptedGroup(groupSecretParams), this::resolveRecipient);
             account.getGroupStore().updateGroup(group);
@@ -2833,6 +2978,11 @@ public class Manager implements Closeable {
         return account.getRecipientStore().resolveRecipientTrusted(address);
     }
 
+    private void enqueueJob(Job job) {
+        var context = new Context(account, accountManager, messageReceiver, stickerPackStore);
+        job.run(context);
+    }
+
     @Override
     public void close() throws IOException {
         close(true);