]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java
Add removeContact command
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / ManagerImpl.java
index 668c0b13204bd3f536dfb6afcc6fbecb12bc806a..269edb3b440d2b384445e798d5d72190e38ee986 100644 (file)
 package org.asamk.signal.manager;
 
 import org.asamk.signal.manager.actions.HandleAction;
+import org.asamk.signal.manager.api.Configuration;
 import org.asamk.signal.manager.api.Device;
 import org.asamk.signal.manager.api.Group;
 import org.asamk.signal.manager.api.Identity;
+import org.asamk.signal.manager.api.InactiveGroupLinkException;
+import org.asamk.signal.manager.api.InvalidDeviceLinkException;
 import org.asamk.signal.manager.api.Message;
+import org.asamk.signal.manager.api.Pair;
 import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.api.SendGroupMessageResults;
+import org.asamk.signal.manager.api.SendMessageResult;
 import org.asamk.signal.manager.api.SendMessageResults;
 import org.asamk.signal.manager.api.TypingAction;
 import org.asamk.signal.manager.api.UpdateGroup;
@@ -64,18 +69,14 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.ecc.ECPublicKey;
-import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.SignalSessionLock;
-import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
-import org.whispersystems.signalservice.api.messages.SendMessageResult;
-import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
 import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
+import org.whispersystems.signalservice.api.push.ACI;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
-import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
 import org.whispersystems.signalservice.api.util.DeviceNameUtil;
 import org.whispersystems.signalservice.api.util.InvalidNumberException;
 import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
@@ -94,6 +95,7 @@ import java.net.URISyntaxException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.SignatureException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -107,6 +109,7 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.locks.ReentrantLock;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import static org.asamk.signal.manager.config.ServiceConfig.capabilities;
 
@@ -135,6 +138,13 @@ public class ManagerImpl implements Manager {
 
     private final Context context;
     private boolean hasCaughtUpWithOldMessages = false;
+    private boolean ignoreAttachments = false;
+
+    private Thread receiveThread;
+    private final Set<ReceiveMessageHandler> weakHandlers = new HashSet<>();
+    private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
+    private final List<Runnable> closedListeners = new ArrayList<>();
+    private boolean isReceivingSynchronous;
 
     ManagerImpl(
             SignalAccount account,
@@ -145,8 +155,8 @@ public class ManagerImpl implements Manager {
         this.account = account;
         this.serviceEnvironmentConfig = serviceEnvironmentConfig;
 
-        final var credentialsProvider = new DynamicCredentialsProvider(account.getUuid(),
-                account.getUsername(),
+        final var credentialsProvider = new DynamicCredentialsProvider(account.getAci(),
+                account.getAccount(),
                 account.getPassword(),
                 account.getDeviceId());
         final var sessionLock = new SignalSessionLock() {
@@ -164,9 +174,9 @@ public class ManagerImpl implements Manager {
                 account.getSignalProtocolStore(),
                 executor,
                 sessionLock);
-        final var avatarStore = new AvatarStore(pathConfig.getAvatarsPath());
-        final var attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath());
-        final var stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath());
+        final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
+        final var attachmentStore = new AttachmentStore(pathConfig.attachmentsPath());
+        final var stickerPackStore = new StickerPackStore(pathConfig.stickerPacksPath());
 
         this.attachmentHelper = new AttachmentHelper(dependencies, attachmentStore);
         this.pinHelper = new PinHelper(dependencies.getKeyBackupService());
@@ -241,7 +251,7 @@ public class ManagerImpl implements Manager {
 
     @Override
     public String getSelfNumber() {
-        return account.getUsername();
+        return account.getAccount();
     }
 
     @Override
@@ -258,8 +268,8 @@ public class ManagerImpl implements Manager {
             }
         }
         preKeyHelper.refreshPreKeysIfNecessary();
-        if (account.getUuid() == null) {
-            account.setUuid(dependencies.getAccountManager().getOwnUuid());
+        if (account.getAci() == null) {
+            account.setAci(dependencies.getAccountManager().getOwnAci());
         }
         updateAccountAttributes(null);
     }
@@ -269,13 +279,17 @@ public class ManagerImpl implements Manager {
      *
      * @param numbers The set of phone number in question
      * @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null.
-     * @throws IOException if its unable to get the contacts to check if they're registered
+     * @throws IOException if it's unable to get the contacts to check if they're registered
      */
     @Override
     public Map<String, Pair<String, UUID>> areUsersRegistered(Set<String> numbers) throws IOException {
         Map<String, String> canonicalizedNumbers = numbers.stream().collect(Collectors.toMap(n -> n, n -> {
             try {
-                return PhoneNumberFormatter.formatNumber(n, account.getUsername());
+                final var canonicalizedNumber = PhoneNumberFormatter.formatNumber(n, account.getAccount());
+                if (!canonicalizedNumber.equals(n)) {
+                    logger.debug("Normalized number {} to {}.", n, canonicalizedNumber);
+                }
+                return canonicalizedNumber;
             } catch (InvalidNumberException e) {
                 return "";
             }
@@ -289,8 +303,8 @@ public class ManagerImpl implements Manager {
 
         return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
             final var number = canonicalizedNumbers.get(n);
-            final var uuid = registeredUsers.get(number);
-            return new Pair<>(number.isEmpty() ? null : number, uuid);
+            final var aci = registeredUsers.get(number);
+            return new Pair<>(number.isEmpty() ? null : number, aci == null ? null : aci.uuid());
         }));
     }
 
@@ -317,29 +331,35 @@ public class ManagerImpl implements Manager {
                         account.isDiscoverableByPhoneNumber());
     }
 
+    @Override
+    public Configuration getConfiguration() {
+        final var configurationStore = account.getConfigurationStore();
+        return new Configuration(java.util.Optional.ofNullable(configurationStore.getReadReceipts()),
+                java.util.Optional.ofNullable(configurationStore.getUnidentifiedDeliveryIndicators()),
+                java.util.Optional.ofNullable(configurationStore.getTypingIndicators()),
+                java.util.Optional.ofNullable(configurationStore.getLinkPreviews()));
+    }
+
     @Override
     public void updateConfiguration(
-            final Boolean readReceipts,
-            final Boolean unidentifiedDeliveryIndicators,
-            final Boolean typingIndicators,
-            final Boolean linkPreviews
+            Configuration configuration
     ) throws IOException, NotMasterDeviceException {
         if (!account.isMasterDevice()) {
             throw new NotMasterDeviceException();
         }
 
         final var configurationStore = account.getConfigurationStore();
-        if (readReceipts != null) {
-            configurationStore.setReadReceipts(readReceipts);
+        if (configuration.readReceipts().isPresent()) {
+            configurationStore.setReadReceipts(configuration.readReceipts().get());
         }
-        if (unidentifiedDeliveryIndicators != null) {
-            configurationStore.setUnidentifiedDeliveryIndicators(unidentifiedDeliveryIndicators);
+        if (configuration.unidentifiedDeliveryIndicators().isPresent()) {
+            configurationStore.setUnidentifiedDeliveryIndicators(configuration.unidentifiedDeliveryIndicators().get());
         }
-        if (typingIndicators != null) {
-            configurationStore.setTypingIndicators(typingIndicators);
+        if (configuration.typingIndicators().isPresent()) {
+            configurationStore.setTypingIndicators(configuration.typingIndicators().get());
         }
-        if (linkPreviews != null) {
-            configurationStore.setLinkPreviews(linkPreviews);
+        if (configuration.linkPreviews().isPresent()) {
+            configurationStore.setLinkPreviews(configuration.linkPreviews().get());
         }
         syncHelper.sendConfigurationMessage();
     }
@@ -353,9 +373,13 @@ public class ManagerImpl implements Manager {
      */
     @Override
     public void setProfile(
-            String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
+            String givenName, final String familyName, String about, String aboutEmoji, java.util.Optional<File> avatar
     ) throws IOException {
-        profileHelper.setProfile(givenName, familyName, about, aboutEmoji, avatar);
+        profileHelper.setProfile(givenName,
+                familyName,
+                about,
+                aboutEmoji,
+                avatar == null ? null : Optional.fromNullable(avatar.orElse(null)));
         syncHelper.sendSyncFetchProfileMessage();
     }
 
@@ -367,13 +391,14 @@ public class ManagerImpl implements Manager {
         dependencies.getAccountManager().setGcmId(Optional.absent());
 
         account.setRegistered(false);
+        close();
     }
 
     @Override
     public void deleteAccount() throws IOException {
         try {
             pinHelper.removeRegistrationLockPin();
-        } catch (UnauthenticatedResponseException e) {
+        } catch (IOException e) {
             logger.warn("Failed to remove registration lock pin");
         }
         account.setRegistrationLockPin(null, null);
@@ -381,10 +406,13 @@ public class ManagerImpl implements Manager {
         dependencies.getAccountManager().deleteAccount();
 
         account.setRegistered(false);
+        close();
     }
 
     @Override
     public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException {
+        captcha = captcha == null ? null : captcha.replace("signalcaptcha://", "");
+
         dependencies.getAccountManager().submitRateLimitRecaptchaChallenge(challenge, captcha);
     }
 
@@ -418,27 +446,33 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException {
+    public void addDeviceLink(URI linkUri) throws IOException, InvalidDeviceLinkException {
         var info = DeviceLinkInfo.parseDeviceLinkUri(linkUri);
 
-        addDevice(info.deviceIdentifier, info.deviceKey);
+        addDevice(info.deviceIdentifier(), info.deviceKey());
     }
 
-    private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException {
+    private void addDevice(
+            String deviceIdentifier, ECPublicKey deviceKey
+    ) throws IOException, InvalidDeviceLinkException {
         var identityKeyPair = account.getIdentityKeyPair();
         var verificationCode = dependencies.getAccountManager().getNewDeviceVerificationCode();
 
-        dependencies.getAccountManager()
-                .addDevice(deviceIdentifier,
-                        deviceKey,
-                        identityKeyPair,
-                        Optional.of(account.getProfileKey().serialize()),
-                        verificationCode);
+        try {
+            dependencies.getAccountManager()
+                    .addDevice(deviceIdentifier,
+                            deviceKey,
+                            identityKeyPair,
+                            Optional.of(account.getProfileKey().serialize()),
+                            verificationCode);
+        } catch (InvalidKeyException e) {
+            throw new InvalidDeviceLinkException("Invalid device link", e);
+        }
         account.setMultiDevice(true);
     }
 
     @Override
-    public void setRegistrationLockPin(Optional<String> pin) throws IOException, UnauthenticatedResponseException {
+    public void setRegistrationLockPin(java.util.Optional<String> pin) throws IOException {
         if (!account.isMasterDevice()) {
             throw new RuntimeException("Only master device can set a PIN");
         }
@@ -463,7 +497,7 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws UnregisteredUserException {
+    public Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws IOException {
         return profileHelper.getRecipientProfile(resolveRecipient(recipient));
     }
 
@@ -553,7 +587,7 @@ public class ManagerImpl implements Manager {
     @Override
     public Pair<GroupId, SendGroupMessageResults> joinGroup(
             GroupInviteLinkUrl inviteLinkUrl
-    ) throws IOException, GroupLinkNotActiveException {
+    ) throws IOException, InactiveGroupLinkException {
         return groupHelper.joinGroup(inviteLinkUrl);
     }
 
@@ -564,66 +598,100 @@ public class ManagerImpl implements Manager {
         long timestamp = System.currentTimeMillis();
         messageBuilder.withTimestamp(timestamp);
         for (final var recipient : recipients) {
-            if (recipient instanceof RecipientIdentifier.Single) {
-                final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient);
+            if (recipient instanceof RecipientIdentifier.Single single) {
+                final var recipientId = resolveRecipient(single);
                 final var result = sendHelper.sendMessage(messageBuilder, recipientId);
-                results.put(recipient, List.of(result));
+                results.put(recipient,
+                        List.of(SendMessageResult.from(result,
+                                account.getRecipientStore(),
+                                account.getRecipientStore()::resolveRecipientAddress)));
             } else if (recipient instanceof RecipientIdentifier.NoteToSelf) {
                 final var result = sendHelper.sendSelfMessage(messageBuilder);
-                results.put(recipient, List.of(result));
-            } else if (recipient instanceof RecipientIdentifier.Group) {
-                final var groupId = ((RecipientIdentifier.Group) recipient).groupId;
-                final var result = sendHelper.sendAsGroupMessage(messageBuilder, groupId);
-                results.put(recipient, result);
+                results.put(recipient,
+                        List.of(SendMessageResult.from(result,
+                                account.getRecipientStore(),
+                                account.getRecipientStore()::resolveRecipientAddress)));
+            } else if (recipient instanceof RecipientIdentifier.Group group) {
+                final var result = sendHelper.sendAsGroupMessage(messageBuilder, group.groupId());
+                results.put(recipient,
+                        result.stream()
+                                .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
+                                        account.getRecipientStore(),
+                                        account.getRecipientStore()::resolveRecipientAddress))
+                                .collect(Collectors.toList()));
             }
         }
         return new SendMessageResults(timestamp, results);
     }
 
-    private void sendTypingMessage(
+    private SendMessageResults sendTypingMessage(
             SignalServiceTypingMessage.Action action, Set<RecipientIdentifier> recipients
-    ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
+    ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
+        var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
         final var timestamp = System.currentTimeMillis();
         for (var recipient : recipients) {
             if (recipient instanceof RecipientIdentifier.Single) {
                 final var message = new SignalServiceTypingMessage(action, timestamp, Optional.absent());
                 final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient);
-                sendHelper.sendTypingMessage(message, recipientId);
+                final var result = sendHelper.sendTypingMessage(message, recipientId);
+                results.put(recipient,
+                        List.of(SendMessageResult.from(result,
+                                account.getRecipientStore(),
+                                account.getRecipientStore()::resolveRecipientAddress)));
             } else if (recipient instanceof RecipientIdentifier.Group) {
-                final var groupId = ((RecipientIdentifier.Group) recipient).groupId;
+                final var groupId = ((RecipientIdentifier.Group) recipient).groupId();
                 final var message = new SignalServiceTypingMessage(action, timestamp, Optional.of(groupId.serialize()));
-                sendHelper.sendGroupTypingMessage(message, groupId);
+                final var result = sendHelper.sendGroupTypingMessage(message, groupId);
+                results.put(recipient,
+                        result.stream()
+                                .map(r -> SendMessageResult.from(r,
+                                        account.getRecipientStore(),
+                                        account.getRecipientStore()::resolveRecipientAddress))
+                                .collect(Collectors.toList()));
             }
         }
+        return new SendMessageResults(timestamp, results);
     }
 
     @Override
-    public void sendTypingMessage(
+    public SendMessageResults sendTypingMessage(
             TypingAction action, Set<RecipientIdentifier> recipients
-    ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
-        sendTypingMessage(action.toSignalService(), recipients);
+    ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
+        return sendTypingMessage(action.toSignalService(), recipients);
     }
 
     @Override
-    public void sendReadReceipt(
+    public SendMessageResults sendReadReceipt(
             RecipientIdentifier.Single sender, List<Long> messageIds
-    ) throws IOException, UntrustedIdentityException {
+    ) throws IOException {
+        final var timestamp = System.currentTimeMillis();
         var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ,
                 messageIds,
-                System.currentTimeMillis());
+                timestamp);
 
-        sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
+        final var result = sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
+        return new SendMessageResults(timestamp,
+                Map.of(sender,
+                        List.of(SendMessageResult.from(result,
+                                account.getRecipientStore(),
+                                account.getRecipientStore()::resolveRecipientAddress))));
     }
 
     @Override
-    public void sendViewedReceipt(
+    public SendMessageResults sendViewedReceipt(
             RecipientIdentifier.Single sender, List<Long> messageIds
-    ) throws IOException, UntrustedIdentityException {
+    ) throws IOException {
+        final var timestamp = System.currentTimeMillis();
         var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED,
                 messageIds,
-                System.currentTimeMillis());
+                timestamp);
 
-        sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
+        final var result = sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
+        return new SendMessageResults(timestamp,
+                Map.of(sender,
+                        List.of(SendMessageResult.from(result,
+                                account.getRecipientStore(),
+                                account.getRecipientStore()::resolveRecipientAddress))));
     }
 
     @Override
@@ -638,11 +706,33 @@ public class ManagerImpl implements Manager {
     private void applyMessage(
             final SignalServiceDataMessage.Builder messageBuilder, final Message message
     ) throws AttachmentInvalidException, IOException {
-        messageBuilder.withBody(message.getMessageText());
-        final var attachments = message.getAttachments();
+        messageBuilder.withBody(message.messageText());
+        final var attachments = message.attachments();
         if (attachments != null) {
             messageBuilder.withAttachments(attachmentHelper.uploadAttachments(attachments));
         }
+        if (message.mentions().size() > 0) {
+            messageBuilder.withMentions(resolveMentions(message.mentions()));
+        }
+        if (message.quote().isPresent()) {
+            final var quote = message.quote().get();
+            messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
+                    resolveSignalServiceAddress(resolveRecipient(quote.author())),
+                    quote.message(),
+                    List.of(),
+                    resolveMentions(quote.mentions())));
+        }
+    }
+
+    private ArrayList<SignalServiceDataMessage.Mention> resolveMentions(final List<Message.Mention> mentionList) throws IOException {
+        final var mentions = new ArrayList<SignalServiceDataMessage.Mention>();
+        for (final var m : mentionList) {
+            final var recipientId = resolveRecipient(m.recipient());
+            mentions.add(new SignalServiceDataMessage.Mention(resolveSignalServiceAddress(recipientId).getAci(),
+                    m.start(),
+                    m.length()));
+        }
+        return mentions;
     }
 
     @Override
@@ -688,10 +778,20 @@ public class ManagerImpl implements Manager {
         }
     }
 
+    @Override
+    public void deleteRecipient(final RecipientIdentifier.Single recipient) throws IOException {
+        account.removeRecipient(resolveRecipient(recipient));
+    }
+
+    @Override
+    public void deleteContact(final RecipientIdentifier.Single recipient) throws IOException {
+        account.getContactStore().deleteContact(resolveRecipient(recipient));
+    }
+
     @Override
     public void setContactName(
             RecipientIdentifier.Single recipient, String name
-    ) throws NotMasterDeviceException, UnregisteredUserException {
+    ) throws NotMasterDeviceException, IOException {
         if (!account.isMasterDevice()) {
             throw new NotMasterDeviceException();
         }
@@ -793,22 +893,22 @@ public class ManagerImpl implements Manager {
         return resolveRecipientTrusted(new SignalServiceAddress(uuid, number));
     }
 
-    private UUID getRegisteredUser(final String number) throws IOException {
-        final Map<String, UUID> uuidMap;
+    private ACI getRegisteredUser(final String number) throws IOException {
+        final Map<String, ACI> aciMap;
         try {
-            uuidMap = getRegisteredUsers(Set.of(number));
+            aciMap = getRegisteredUsers(Set.of(number));
         } catch (NumberFormatException e) {
-            throw new UnregisteredUserException(number, e);
+            throw new IOException(number, e);
         }
-        final var uuid = uuidMap.get(number);
+        final var uuid = aciMap.get(number);
         if (uuid == null) {
-            throw new UnregisteredUserException(number, null);
+            throw new IOException(number, null);
         }
         return uuid;
     }
 
-    private Map<String, UUID> getRegisteredUsers(final Set<String> numbers) throws IOException {
-        final Map<String, UUID> registeredUsers;
+    private Map<String, ACI> getRegisteredUsers(final Set<String> numbers) throws IOException {
+        final Map<String, ACI> registeredUsers;
         try {
             registeredUsers = dependencies.getAccountManager()
                     .getRegisteredUsers(ServiceConfig.getIasKeyStore(),
@@ -818,16 +918,16 @@ public class ManagerImpl implements Manager {
             throw new IOException(e);
         }
 
-        // Store numbers as recipients so we have the number/uuid association
-        registeredUsers.forEach((number, uuid) -> resolveRecipientTrusted(new SignalServiceAddress(uuid, number)));
+        // Store numbers as recipients, so we have the number/uuid association
+        registeredUsers.forEach((number, aci) -> resolveRecipientTrusted(new SignalServiceAddress(aci, number)));
 
         return registeredUsers;
     }
 
-    private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) {
+    private void retryFailedReceivedMessages(ReceiveMessageHandler handler) {
         Set<HandleAction> queuedActions = new HashSet<>();
         for (var cachedMessage : account.getMessageCache().getCachedMessages()) {
-            var actions = retryFailedReceivedMessage(handler, ignoreAttachments, cachedMessage);
+            var actions = retryFailedReceivedMessage(handler, cachedMessage);
             if (actions != null) {
                 queuedActions.addAll(actions);
             }
@@ -836,7 +936,7 @@ public class ManagerImpl implements Manager {
     }
 
     private List<HandleAction> retryFailedReceivedMessage(
-            final ReceiveMessageHandler handler, final boolean ignoreAttachments, final CachedMessage cachedMessage
+            final ReceiveMessageHandler handler, final CachedMessage cachedMessage
     ) {
         var envelope = cachedMessage.loadEnvelope();
         if (envelope == null) {
@@ -872,26 +972,143 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void receiveMessages(
-            long timeout,
-            TimeUnit unit,
-            boolean returnOnTimeout,
-            boolean ignoreAttachments,
-            ReceiveMessageHandler handler
+    public void addReceiveHandler(final ReceiveMessageHandler handler, final boolean isWeakListener) {
+        if (isReceivingSynchronous) {
+            throw new IllegalStateException("Already receiving message synchronously.");
+        }
+        synchronized (messageHandlers) {
+            if (isWeakListener) {
+                weakHandlers.add(handler);
+            } else {
+                messageHandlers.add(handler);
+                startReceiveThreadIfRequired();
+            }
+        }
+    }
+
+    private void startReceiveThreadIfRequired() {
+        if (receiveThread != null) {
+            return;
+        }
+        receiveThread = new Thread(() -> {
+            logger.debug("Starting receiving messages");
+            while (!Thread.interrupted()) {
+                try {
+                    receiveMessagesInternal(1L, TimeUnit.HOURS, false, (envelope, e) -> {
+                        synchronized (messageHandlers) {
+                            Stream.concat(messageHandlers.stream(), weakHandlers.stream()).forEach(h -> {
+                                try {
+                                    h.handleMessage(envelope, e);
+                                } catch (Exception ex) {
+                                    logger.warn("Message handler failed, ignoring", ex);
+                                }
+                            });
+                        }
+                    });
+                    break;
+                } catch (IOException e) {
+                    logger.warn("Receiving messages failed, retrying", e);
+                }
+            }
+            logger.debug("Finished receiving messages");
+            hasCaughtUpWithOldMessages = false;
+            synchronized (messageHandlers) {
+                receiveThread = null;
+
+                // Check if in the meantime another handler has been registered
+                if (!messageHandlers.isEmpty()) {
+                    logger.debug("Another handler has been registered, starting receive thread again");
+                    startReceiveThreadIfRequired();
+                }
+            }
+        });
+
+        receiveThread.start();
+    }
+
+    @Override
+    public void removeReceiveHandler(final ReceiveMessageHandler handler) {
+        final Thread thread;
+        synchronized (messageHandlers) {
+            weakHandlers.remove(handler);
+            messageHandlers.remove(handler);
+            if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) {
+                return;
+            }
+            thread = receiveThread;
+            receiveThread = null;
+        }
+
+        stopReceiveThread(thread);
+    }
+
+    private void stopReceiveThread(final Thread thread) {
+        thread.interrupt();
+        try {
+            thread.join();
+        } catch (InterruptedException ignored) {
+        }
+    }
+
+    @Override
+    public boolean isReceiving() {
+        if (isReceivingSynchronous) {
+            return true;
+        }
+        synchronized (messageHandlers) {
+            return messageHandlers.size() > 0;
+        }
+    }
+
+    @Override
+    public void receiveMessages(long timeout, TimeUnit unit, ReceiveMessageHandler handler) throws IOException {
+        receiveMessages(timeout, unit, true, handler);
+    }
+
+    @Override
+    public void receiveMessages(ReceiveMessageHandler handler) throws IOException {
+        receiveMessages(1L, TimeUnit.HOURS, false, handler);
+    }
+
+    private void receiveMessages(
+            long timeout, TimeUnit unit, boolean returnOnTimeout, ReceiveMessageHandler handler
+    ) throws IOException {
+        if (isReceiving()) {
+            throw new IllegalStateException("Already receiving message.");
+        }
+        isReceivingSynchronous = true;
+        receiveThread = Thread.currentThread();
+        try {
+            receiveMessagesInternal(timeout, unit, returnOnTimeout, handler);
+        } finally {
+            receiveThread = null;
+            hasCaughtUpWithOldMessages = false;
+            isReceivingSynchronous = false;
+        }
+    }
+
+    private void receiveMessagesInternal(
+            long timeout, TimeUnit unit, boolean returnOnTimeout, ReceiveMessageHandler handler
     ) throws IOException {
-        retryFailedReceivedMessages(handler, ignoreAttachments);
+        retryFailedReceivedMessages(handler);
 
-        Set<HandleAction> queuedActions = new HashSet<>();
+        // Use a Map here because java Set doesn't have a get method ...
+        Map<HandleAction, HandleAction> queuedActions = new HashMap<>();
 
         final var signalWebSocket = dependencies.getSignalWebSocket();
         signalWebSocket.connect();
 
         hasCaughtUpWithOldMessages = false;
+        var backOffCounter = 0;
+        final var MAX_BACKOFF_COUNTER = 9;
 
         while (!Thread.interrupted()) {
             SignalServiceEnvelope envelope;
             final CachedMessage[] cachedMessage = {null};
-            account.setLastReceiveTimestamp(System.currentTimeMillis());
+            final var nowMillis = System.currentTimeMillis();
+            if (nowMillis - account.getLastReceiveTimestamp() > 60000) {
+                account.setLastReceiveTimestamp(nowMillis);
+            }
             logger.debug("Checking for new message from server");
             try {
                 var result = signalWebSocket.readOrEmpty(unit.toMillis(timeout), envelope1 -> {
@@ -901,12 +1118,14 @@ public class ManagerImpl implements Manager {
                     // store message on disk, before acknowledging receipt to the server
                     cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
                 });
+                backOffCounter = 0;
+
                 if (result.isPresent()) {
                     envelope = result.get();
                     logger.debug("New message received from server");
                 } else {
                     logger.debug("Received indicator that server queue is empty");
-                    handleQueuedActions(queuedActions);
+                    handleQueuedActions(queuedActions.keySet());
                     queuedActions.clear();
 
                     hasCaughtUpWithOldMessages = true;
@@ -924,21 +1143,41 @@ public class ManagerImpl implements Manager {
                 } else {
                     throw e;
                 }
-            } catch (WebSocketUnavailableException e) {
-                logger.debug("Pipe unexpectedly unavailable, connecting");
-                signalWebSocket.connect();
-                continue;
+            } catch (IOException e) {
+                logger.debug("Pipe unexpectedly unavailable: {}", e.getMessage());
+                if (e instanceof WebSocketUnavailableException || "Connection closed!".equals(e.getMessage())) {
+                    final var sleepMilliseconds = 100 * (long) Math.pow(2, backOffCounter);
+                    backOffCounter = Math.min(backOffCounter + 1, MAX_BACKOFF_COUNTER);
+                    logger.warn("Connection closed unexpectedly, reconnecting in {} ms", sleepMilliseconds);
+                    try {
+                        Thread.sleep(sleepMilliseconds);
+                    } catch (InterruptedException interruptedException) {
+                        return;
+                    }
+                    hasCaughtUpWithOldMessages = false;
+                    signalWebSocket.connect();
+                    continue;
+                }
+                throw e;
             } catch (TimeoutException e) {
+                backOffCounter = 0;
                 if (returnOnTimeout) return;
                 continue;
             }
 
             final var result = incomingMessageHandler.handleEnvelope(envelope, ignoreAttachments, handler);
-            queuedActions.addAll(result.first());
+            for (final var h : result.first()) {
+                final var existingAction = queuedActions.get(h);
+                if (existingAction == null) {
+                    queuedActions.put(h, h);
+                } else {
+                    existingAction.mergeOther(h);
+                }
+            }
             final var exception = result.second();
 
             if (hasCaughtUpWithOldMessages) {
-                handleQueuedActions(queuedActions);
+                handleQueuedActions(queuedActions.keySet());
                 queuedActions.clear();
             }
             if (cachedMessage[0] != null) {
@@ -959,8 +1198,14 @@ public class ManagerImpl implements Manager {
                 }
             }
         }
-        handleQueuedActions(queuedActions);
+        handleQueuedActions(queuedActions.keySet());
         queuedActions.clear();
+        dependencies.getSignalWebSocket().disconnect();
+    }
+
+    @Override
+    public void setIgnoreAttachments(final boolean ignoreAttachments) {
+        this.ignoreAttachments = ignoreAttachments;
     }
 
     @Override
@@ -993,17 +1238,12 @@ public class ManagerImpl implements Manager {
         final RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return false;
         }
         return contactHelper.isContactBlocked(recipientId);
     }
 
-    @Override
-    public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) {
-        return attachmentHelper.getAttachmentFile(attachmentId);
-    }
-
     @Override
     public void sendContacts() throws IOException {
         syncHelper.sendContacts();
@@ -1023,7 +1263,7 @@ public class ManagerImpl implements Manager {
         final RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return null;
         }
 
@@ -1064,11 +1304,12 @@ public class ManagerImpl implements Manager {
         }
 
         final var address = account.getRecipientStore().resolveRecipientAddress(identityInfo.getRecipientId());
+        final var scannableFingerprint = identityHelper.computeSafetyNumberForScanning(identityInfo.getRecipientId(),
+                identityInfo.getIdentityKey());
         return new Identity(address,
                 identityInfo.getIdentityKey(),
                 identityHelper.computeSafetyNumber(identityInfo.getRecipientId(), identityInfo.getIdentityKey()),
-                identityHelper.computeSafetyNumberForScanning(identityInfo.getRecipientId(),
-                        identityInfo.getIdentityKey()).getSerialized(),
+                scannableFingerprint == null ? null : scannableFingerprint.getSerialized(),
                 identityInfo.getTrustLevel(),
                 identityInfo.getDateAdded());
     }
@@ -1078,7 +1319,7 @@ public class ManagerImpl implements Manager {
         IdentityInfo identity;
         try {
             identity = account.getIdentityKeyStore().getIdentity(resolveRecipient(recipient));
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             identity = null;
         }
         return identity == null ? List.of() : List.of(toIdentity(identity));
@@ -1087,7 +1328,7 @@ public class ManagerImpl implements Manager {
     /**
      * Trust this the identity with this fingerprint
      *
-     * @param recipient   username of the identity
+     * @param recipient   account of the identity
      * @param fingerprint Fingerprint
      */
     @Override
@@ -1095,7 +1336,7 @@ public class ManagerImpl implements Manager {
         RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return false;
         }
         return identityHelper.trustIdentityVerified(recipientId, fingerprint);
@@ -1104,7 +1345,7 @@ public class ManagerImpl implements Manager {
     /**
      * Trust this the identity with this safety number
      *
-     * @param recipient    username of the identity
+     * @param recipient    account of the identity
      * @param safetyNumber Safety number
      */
     @Override
@@ -1112,7 +1353,7 @@ public class ManagerImpl implements Manager {
         RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return false;
         }
         return identityHelper.trustIdentityVerifiedSafetyNumber(recipientId, safetyNumber);
@@ -1121,7 +1362,7 @@ public class ManagerImpl implements Manager {
     /**
      * Trust this the identity with this scannable safety number
      *
-     * @param recipient    username of the identity
+     * @param recipient    account of the identity
      * @param safetyNumber Scannable safety number
      */
     @Override
@@ -1129,7 +1370,7 @@ public class ManagerImpl implements Manager {
         RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return false;
         }
         return identityHelper.trustIdentityVerifiedSafetyNumber(recipientId, safetyNumber);
@@ -1138,30 +1379,33 @@ public class ManagerImpl implements Manager {
     /**
      * Trust all keys of this identity without verification
      *
-     * @param recipient username of the identity
+     * @param recipient account of the identity
      */
     @Override
     public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) {
         RecipientId recipientId;
         try {
             recipientId = resolveRecipient(recipient);
-        } catch (UnregisteredUserException e) {
+        } catch (IOException e) {
             return false;
         }
         return identityHelper.trustIdentityAllKeys(recipientId);
     }
 
+    @Override
+    public void addClosedListener(final Runnable listener) {
+        synchronized (closedListeners) {
+            closedListeners.add(listener);
+        }
+    }
+
     private void handleIdentityFailure(
-            final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure
+            final RecipientId recipientId,
+            final org.whispersystems.signalservice.api.messages.SendMessageResult.IdentityFailure identityFailure
     ) {
         this.identityHelper.handleIdentityFailure(recipientId, identityFailure);
     }
 
-    @Override
-    public SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address) {
-        return resolveSignalServiceAddress(resolveRecipient(address));
-    }
-
     private SignalServiceAddress resolveSignalServiceAddress(RecipientId recipientId) {
         final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
         if (address.getUuid().isPresent()) {
@@ -1171,18 +1415,18 @@ public class ManagerImpl implements Manager {
         // Address in recipient store doesn't have a uuid, this shouldn't happen
         // Try to retrieve the uuid from the server
         final var number = address.getNumber().get();
-        final UUID uuid;
+        final ACI aci;
         try {
-            uuid = getRegisteredUser(number);
+            aci = getRegisteredUser(number);
         } catch (IOException e) {
             logger.warn("Failed to get uuid for e164 number: {}", number, e);
             // Return SignalServiceAddress with unknown UUID
             return address.toSignalServiceAddress();
         }
-        return resolveSignalServiceAddress(account.getRecipientStore().resolveRecipient(uuid));
+        return resolveSignalServiceAddress(account.getRecipientStore().resolveRecipient(aci));
     }
 
-    private Set<RecipientId> resolveRecipients(Collection<RecipientIdentifier.Single> recipients) throws UnregisteredUserException {
+    private Set<RecipientId> resolveRecipients(Collection<RecipientIdentifier.Single> recipients) throws IOException {
         final var recipientIds = new HashSet<RecipientId>(recipients.size());
         for (var number : recipients) {
             final var recipientId = resolveRecipient(number);
@@ -1191,11 +1435,11 @@ public class ManagerImpl implements Manager {
         return recipientIds;
     }
 
-    private RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredUserException {
-        if (recipient instanceof RecipientIdentifier.Uuid) {
-            return account.getRecipientStore().resolveRecipient(((RecipientIdentifier.Uuid) recipient).uuid);
+    private RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws IOException {
+        if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) {
+            return account.getRecipientStore().resolveRecipient(ACI.from(uuidRecipient.uuid()));
         } else {
-            final var number = ((RecipientIdentifier.Number) recipient).number;
+            final var number = ((RecipientIdentifier.Number) recipient).number();
             return account.getRecipientStore().resolveRecipient(number, () -> {
                 try {
                     return getRegisteredUser(number);
@@ -1206,6 +1450,10 @@ public class ManagerImpl implements Manager {
         }
     }
 
+    private RecipientId resolveRecipient(RecipientAddress address) {
+        return account.getRecipientStore().resolveRecipient(address);
+    }
+
     private RecipientId resolveRecipient(SignalServiceAddress address) {
         return account.getRecipientStore().resolveRecipient(address);
     }
@@ -1216,15 +1464,26 @@ public class ManagerImpl implements Manager {
 
     @Override
     public void close() throws IOException {
-        close(true);
-    }
-
-    private void close(boolean closeAccount) throws IOException {
+        Thread thread;
+        synchronized (messageHandlers) {
+            weakHandlers.clear();
+            messageHandlers.clear();
+            thread = receiveThread;
+            receiveThread = null;
+        }
+        if (thread != null) {
+            stopReceiveThread(thread);
+        }
         executor.shutdown();
 
         dependencies.getSignalWebSocket().disconnect();
 
-        if (closeAccount && account != null) {
+        synchronized (closedListeners) {
+            closedListeners.forEach(Runnable::run);
+            closedListeners.clear();
+        }
+
+        if (account != null) {
             account.close();
         }
         account = null;