]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java
Add support for sending view once messages
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / internal / ManagerImpl.java
index 189e4fe951fc670ccab5ea1822fdca7aa0bd31c0..726d0aafbffea9819adf971beccc97017b47ca22 100644 (file)
@@ -19,9 +19,11 @@ package org.asamk.signal.manager.internal;
 import org.asamk.signal.manager.Manager;
 import org.asamk.signal.manager.api.AlreadyReceivingException;
 import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.manager.api.CaptchaRejectedException;
 import org.asamk.signal.manager.api.CaptchaRequiredException;
 import org.asamk.signal.manager.api.Configuration;
 import org.asamk.signal.manager.api.Device;
+import org.asamk.signal.manager.api.DeviceLimitExceededException;
 import org.asamk.signal.manager.api.DeviceLinkUrl;
 import org.asamk.signal.manager.api.Group;
 import org.asamk.signal.manager.api.GroupId;
@@ -33,16 +35,20 @@ import org.asamk.signal.manager.api.IdentityVerificationCode;
 import org.asamk.signal.manager.api.InactiveGroupLinkException;
 import org.asamk.signal.manager.api.IncorrectPinException;
 import org.asamk.signal.manager.api.InvalidDeviceLinkException;
+import org.asamk.signal.manager.api.InvalidNumberException;
 import org.asamk.signal.manager.api.InvalidStickerException;
 import org.asamk.signal.manager.api.InvalidUsernameException;
 import org.asamk.signal.manager.api.LastGroupAdminException;
 import org.asamk.signal.manager.api.Message;
 import org.asamk.signal.manager.api.MessageEnvelope;
+import org.asamk.signal.manager.api.MessageEnvelope.Sync.MessageRequestResponse;
 import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
 import org.asamk.signal.manager.api.NotAGroupMemberException;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
 import org.asamk.signal.manager.api.Pair;
 import org.asamk.signal.manager.api.PendingAdminApprovalException;
+import org.asamk.signal.manager.api.PhoneNumberSharingMode;
+import org.asamk.signal.manager.api.PinLockMissingException;
 import org.asamk.signal.manager.api.PinLockedException;
 import org.asamk.signal.manager.api.Profile;
 import org.asamk.signal.manager.api.RateLimitException;
@@ -61,15 +67,21 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
 import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.api.UpdateProfile;
 import org.asamk.signal.manager.api.UserStatus;
+import org.asamk.signal.manager.api.UsernameLinkUrl;
+import org.asamk.signal.manager.api.UsernameStatus;
+import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
 import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
 import org.asamk.signal.manager.helper.AccountFileUpdater;
 import org.asamk.signal.manager.helper.Context;
 import org.asamk.signal.manager.helper.RecipientHelper.RegisteredUser;
+import org.asamk.signal.manager.jobs.RefreshRecipientsJob;
+import org.asamk.signal.manager.jobs.SyncStorageJob;
 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.identities.IdentityInfo;
+import org.asamk.signal.manager.storage.recipients.RecipientAddress;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
 import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
@@ -77,29 +89,32 @@ import org.asamk.signal.manager.storage.stickers.StickerPack;
 import org.asamk.signal.manager.util.AttachmentUtils;
 import org.asamk.signal.manager.util.KeyUtils;
 import org.asamk.signal.manager.util.MimeUtils;
+import org.asamk.signal.manager.util.PhoneNumberFormatter;
 import org.asamk.signal.manager.util.StickerUtils;
 import org.signal.libsignal.protocol.InvalidMessageException;
 import org.signal.libsignal.usernames.BaseUsernameException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.whispersystems.signalservice.api.SignalSessionLock;
+import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
 import org.whispersystems.signalservice.api.messages.SignalServicePreview;
 import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
 import org.whispersystems.signalservice.api.push.ServiceId;
 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
+import org.whispersystems.signalservice.api.push.ServiceId.PNI;
 import org.whispersystems.signalservice.api.push.ServiceIdType;
 import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
+import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
 import org.whispersystems.signalservice.api.util.DeviceNameUtil;
-import org.whispersystems.signalservice.api.util.InvalidNumberException;
-import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
 import org.whispersystems.signalservice.api.util.StreamDetails;
 import org.whispersystems.signalservice.internal.util.Hex;
 import org.whispersystems.signalservice.internal.util.Util;
 
 import java.io.ByteArrayInputStream;
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
@@ -115,17 +130,24 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.locks.ReentrantLock;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import io.reactivex.rxjava3.disposables.CompositeDisposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import okio.Utf8;
+
+import static org.asamk.signal.manager.config.ServiceConfig.MAX_MESSAGE_SIZE_BYTES;
+import static org.asamk.signal.manager.util.Utils.handleResponseException;
+import static org.signal.core.util.StringExtensionsKt.splitByByteLength;
 
 public class ManagerImpl implements Manager {
 
-    private final static Logger logger = LoggerFactory.getLogger(ManagerImpl.class);
+    private static final Logger logger = LoggerFactory.getLogger(ManagerImpl.class);
 
     private SignalAccount account;
     private final SignalDependencies dependencies;
@@ -140,6 +162,7 @@ public class ManagerImpl implements Manager {
     private final List<Runnable> closedListeners = new ArrayList<>();
     private final List<Runnable> addressChangedListeners = new ArrayList<>();
     private final CompositeDisposable disposable = new CompositeDisposable();
+    private final AtomicLong lastMessageTimestamp = new AtomicLong();
 
     public ManagerImpl(
             SignalAccount account,
@@ -150,15 +173,7 @@ public class ManagerImpl implements Manager {
     ) {
         this.account = account;
 
-        final var sessionLock = new SignalSessionLock() {
-            private final ReentrantLock LEGACY_LOCK = new ReentrantLock();
-
-            @Override
-            public Lock acquire() {
-                LEGACY_LOCK.lock();
-                return LEGACY_LOCK::unlock;
-            }
-        };
+        final var sessionLock = new ReentrantSignalSessionLock();
         this.dependencies = new SignalDependencies(serviceEnvironmentConfig,
                 userAgent,
                 account.getCredentialsProvider(),
@@ -190,22 +205,25 @@ public class ManagerImpl implements Manager {
                 this.notifyAll();
             }
         });
-        disposable.add(account.getIdentityKeyStore().getIdentityChanges().subscribe(serviceId -> {
-            logger.trace("Archiving old sessions for {}", serviceId);
-            account.getAccountData(ServiceIdType.ACI).getSessionStore().archiveSessions(serviceId);
-            account.getAccountData(ServiceIdType.PNI).getSessionStore().archiveSessions(serviceId);
-            account.getSenderKeyStore().deleteSharedWith(serviceId);
-            final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
-            final var profile = account.getProfileStore().getProfile(recipientId);
-            if (profile != null) {
-                account.getProfileStore()
-                        .storeProfile(recipientId,
-                                Profile.newBuilder(profile)
-                                        .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
-                                        .withLastUpdateTimestamp(0)
-                                        .build());
-            }
-        }));
+        disposable.add(account.getIdentityKeyStore()
+                .getIdentityChanges()
+                .observeOn(Schedulers.from(executor))
+                .subscribe(serviceId -> {
+                    logger.trace("Archiving old sessions for {}", serviceId);
+                    account.getAccountData(ServiceIdType.ACI).getSessionStore().archiveSessions(serviceId);
+                    account.getAccountData(ServiceIdType.PNI).getSessionStore().archiveSessions(serviceId);
+                    account.getSenderKeyStore().deleteSharedWith(serviceId);
+                    final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
+                    final var profile = account.getProfileStore().getProfile(recipientId);
+                    if (profile != null) {
+                        account.getProfileStore()
+                                .storeProfile(recipientId,
+                                        Profile.newBuilder(profile)
+                                                .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
+                                                .withLastUpdateTimestamp(0)
+                                                .build());
+                    }
+                }));
     }
 
     @Override
@@ -215,6 +233,12 @@ public class ManagerImpl implements Manager {
 
     public void checkAccountState() throws IOException {
         context.getAccountHelper().checkAccountState();
+        final var lastRecipientsRefresh = account.getLastRecipientsRefresh();
+        if (lastRecipientsRefresh == null
+                || lastRecipientsRefresh < System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) {
+            context.getJobExecutor().enqueueJob(new RefreshRecipientsJob());
+            context.getAccountHelper().checkWhoAmiI();
+        }
     }
 
     @Override
@@ -261,10 +285,54 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void updateAccountAttributes(String deviceName) throws IOException {
+    public Map<String, UsernameStatus> getUsernameStatus(Set<String> usernames) throws IOException {
+        final var registeredUsers = new HashMap<String, RecipientAddress>();
+        for (final var username : usernames) {
+            try {
+                final var recipientId = context.getRecipientHelper().resolveRecipientByUsernameOrLink(username, true);
+                final var address = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
+                registeredUsers.put(username, address);
+            } catch (UnregisteredRecipientException e) {
+                // ignore
+            }
+        }
+
+        return usernames.stream().collect(Collectors.toMap(n -> n, username -> {
+            final var user = registeredUsers.get(username);
+            final var serviceId = user == null ? null : user.serviceId().orElse(null);
+            final var profile = serviceId == null
+                    ? null
+                    : context.getProfileHelper()
+                            .getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
+            return new UsernameStatus(username,
+                    serviceId == null ? null : serviceId.getRawUuid(),
+                    profile != null
+                            && profile.getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNRESTRICTED);
+        }));
+    }
+
+    @Override
+    public void updateAccountAttributes(
+            String deviceName,
+            Boolean unrestrictedUnidentifiedSender,
+            final Boolean discoverableByNumber,
+            final Boolean numberSharing
+    ) throws IOException {
         if (deviceName != null) {
             context.getAccountHelper().setDeviceName(deviceName);
         }
+        if (unrestrictedUnidentifiedSender != null) {
+            account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedSender);
+        }
+        if (discoverableByNumber != null) {
+            account.getConfigurationStore().setPhoneNumberUnlisted(!discoverableByNumber);
+        }
+        if (numberSharing != null) {
+            account.getConfigurationStore()
+                    .setPhoneNumberSharingMode(numberSharing
+                            ? PhoneNumberSharingMode.EVERYBODY
+                            : PhoneNumberSharingMode.NOBODY);
+        }
         context.getAccountHelper().updateAccountAttributes();
         context.getAccountHelper().checkWhoAmiI();
     }
@@ -276,13 +344,7 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void updateConfiguration(
-            Configuration configuration
-    ) throws NotPrimaryDeviceException {
-        if (!account.isPrimaryDevice()) {
-            throw new NotPrimaryDeviceException();
-        }
-
+    public void updateConfiguration(Configuration configuration) {
         final var configurationStore = account.getConfigurationStore();
         if (configuration.readReceipts().isPresent()) {
             configurationStore.setReadReceipts(configuration.readReceipts().get());
@@ -297,6 +359,7 @@ public class ManagerImpl implements Manager {
             configurationStore.setLinkPreviews(configuration.linkPreviews().get());
         }
         context.getSyncHelper().sendConfigurationMessage();
+        syncRemoteStorage();
     }
 
     @Override
@@ -318,9 +381,27 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public String setUsername(final String username) throws IOException, InvalidUsernameException {
+    public String getUsername() {
+        return account.getUsername();
+    }
+
+    @Override
+    public UsernameLinkUrl getUsernameLink() {
+        return new UsernameLinkUrl(account.getUsernameLink());
+    }
+
+    @Override
+    public void setUsername(final String username) throws IOException, InvalidUsernameException {
         try {
-            return context.getAccountHelper().reserveUsername(username);
+            if (username.contains(".")) {
+                context.getAccountHelper().reserveExactUsername(username);
+            } else {
+                context.getAccountHelper().reserveUsernameFromNickname(username);
+            }
+        } catch (UsernameMalformedException e) {
+            throw new InvalidUsernameException("Username is malformed", e);
+        } catch (UsernameTakenException e) {
+            throw new InvalidUsernameException("Username is already registered", e);
         } catch (BaseUsernameException e) {
             throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
         }
@@ -333,8 +414,10 @@ public class ManagerImpl implements Manager {
 
     @Override
     public void startChangeNumber(
-            String newNumber, boolean voiceVerification, String captcha
-    ) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException {
+            String newNumber,
+            boolean voiceVerification,
+            String captcha
+    ) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException, VerificationMethodNotAvailableException {
         if (!account.isPrimaryDevice()) {
             throw new NotPrimaryDeviceException();
         }
@@ -343,8 +426,10 @@ public class ManagerImpl implements Manager {
 
     @Override
     public void finishChangeNumber(
-            String newNumber, String verificationCode, String pin
-    ) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException {
+            String newNumber,
+            String verificationCode,
+            String pin
+    ) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException, PinLockMissingException {
         if (!account.isPrimaryDevice()) {
             throw new NotPrimaryDeviceException();
         }
@@ -362,15 +447,22 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException {
-        captcha = captcha == null ? null : captcha.replace("signalcaptcha://", "");
+    public void submitRateLimitRecaptchaChallenge(
+            String challenge,
+            String captcha
+    ) throws IOException, CaptchaRejectedException {
+        captcha = captcha == null ? "" : captcha.replace("signalcaptcha://", "");
 
-        dependencies.getAccountManager().submitRateLimitRecaptchaChallenge(challenge, captcha);
+        try {
+            handleResponseException(dependencies.getRateLimitChallengeApi().submitCaptchaChallenge(challenge, captcha));
+        } catch (org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException ignored) {
+            throw new CaptchaRejectedException();
+        }
     }
 
     @Override
     public List<Device> getLinkedDevices() throws IOException {
-        var devices = dependencies.getAccountManager().getDevices();
+        var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
         account.setMultiDevice(devices.size() > 1);
         var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
         return devices.stream().map(d -> {
@@ -391,12 +483,15 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void removeLinkedDevices(int deviceId) throws IOException {
+    public void removeLinkedDevices(int deviceId) throws IOException, NotPrimaryDeviceException {
+        if (!account.isPrimaryDevice()) {
+            throw new NotPrimaryDeviceException();
+        }
         context.getAccountHelper().removeLinkedDevices(deviceId);
     }
 
     @Override
-    public void addDeviceLink(DeviceLinkUrl linkUrl) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException {
+    public void addDeviceLink(DeviceLinkUrl linkUrl) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException, DeviceLimitExceededException {
         if (!account.isPrimaryDevice()) {
             throw new NotPrimaryDeviceException();
         }
@@ -421,7 +516,7 @@ public class ManagerImpl implements Manager {
 
     @Override
     public List<Group> getGroups() {
-        return account.getGroupStore().getGroups().stream().map(this::toGroup).toList();
+        return context.getGroupHelper().getGroups().stream().map(this::toGroup).toList();
     }
 
     private Group toGroup(final GroupInfo groupInfo) {
@@ -434,7 +529,8 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendGroupMessageResults quitGroup(
-            GroupId groupId, Set<RecipientIdentifier.Single> groupAdmins
+            GroupId groupId,
+            Set<RecipientIdentifier.Single> groupAdmins
     ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException, UnregisteredRecipientException {
         final var newAdmins = context.getRecipientHelper().resolveRecipients(groupAdmins);
         return context.getGroupHelper().quitGroup(groupId, newAdmins);
@@ -452,7 +548,9 @@ public class ManagerImpl implements Manager {
 
     @Override
     public Pair<GroupId, SendGroupMessageResults> createGroup(
-            String name, Set<RecipientIdentifier.Single> members, String avatarFile
+            String name,
+            Set<RecipientIdentifier.Single> members,
+            String avatarFile
     ) throws IOException, AttachmentInvalidException, UnregisteredRecipientException {
         return context.getGroupHelper()
                 .createGroup(name,
@@ -462,7 +560,8 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendGroupMessageResults updateGroup(
-            final GroupId groupId, final UpdateGroup updateGroup
+            final GroupId groupId,
+            final UpdateGroup updateGroup
     ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException, UnregisteredRecipientException {
         return context.getGroupHelper()
                 .updateGroup(groupId,
@@ -502,22 +601,52 @@ public class ManagerImpl implements Manager {
         return context.getGroupHelper().joinGroup(inviteLinkUrl);
     }
 
+    private long getNextMessageTimestamp() {
+        while (true) {
+            final var last = lastMessageTimestamp.get();
+            final var timestamp = System.currentTimeMillis();
+            if (last == timestamp) {
+                try {
+                    Thread.sleep(1);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                continue;
+            }
+            if (lastMessageTimestamp.compareAndSet(last, timestamp)) {
+                return timestamp;
+            }
+        }
+    }
+
     private SendMessageResults sendMessage(
-            SignalServiceDataMessage.Builder messageBuilder, Set<RecipientIdentifier> recipients
+            SignalServiceDataMessage.Builder messageBuilder,
+            Set<RecipientIdentifier> recipients,
+            boolean notifySelf
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
-        return sendMessage(messageBuilder, recipients, Optional.empty());
+        return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty());
     }
 
     private SendMessageResults sendMessage(
             SignalServiceDataMessage.Builder messageBuilder,
             Set<RecipientIdentifier> recipients,
+            boolean notifySelf,
             Optional<Long> editTargetTimestamp
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
         var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
-        long timestamp = System.currentTimeMillis();
+        long timestamp = getNextMessageTimestamp();
         messageBuilder.withTimestamp(timestamp);
         for (final var recipient : recipients) {
-            if (recipient instanceof RecipientIdentifier.Single single) {
+            if (recipient instanceof RecipientIdentifier.NoteToSelf || (
+                    recipient instanceof RecipientIdentifier.Single single
+                            && new RecipientAddress(single.toPartialRecipientAddress()).matches(account.getSelfRecipientAddress())
+            )) {
+                final var result = notifySelf
+                        ? context.getSendHelper()
+                        .sendMessage(messageBuilder, account.getSelfRecipientId(), editTargetTimestamp)
+                        : context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
+                results.put(recipient, List.of(toSendMessageResult(result)));
+            } else if (recipient instanceof RecipientIdentifier.Single single) {
                 try {
                     final var recipientId = context.getRecipientHelper().resolveRecipient(single);
                     final var result = context.getSendHelper()
@@ -527,12 +656,9 @@ public class ManagerImpl implements Manager {
                     results.put(recipient,
                             List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
                 }
-            } else if (recipient instanceof RecipientIdentifier.NoteToSelf) {
-                final var result = context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
-                results.put(recipient, List.of(toSendMessageResult(result)));
             } else if (recipient instanceof RecipientIdentifier.Group group) {
                 final var result = context.getSendHelper()
-                        .sendAsGroupMessage(messageBuilder, group.groupId(), editTargetTimestamp);
+                        .sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp);
                 results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
             }
         }
@@ -544,10 +670,11 @@ public class ManagerImpl implements Manager {
     }
 
     private SendMessageResults sendTypingMessage(
-            SignalServiceTypingMessage.Action action, Set<RecipientIdentifier> recipients
+            SignalServiceTypingMessage.Action action,
+            Set<RecipientIdentifier> recipients
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
         var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
-        final var timestamp = System.currentTimeMillis();
+        final var timestamp = getNextMessageTimestamp();
         for (var recipient : recipients) {
             if (recipient instanceof RecipientIdentifier.Single single) {
                 final var message = new SignalServiceTypingMessage(action, timestamp, Optional.empty());
@@ -571,16 +698,15 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendMessageResults sendTypingMessage(
-            TypingAction action, Set<RecipientIdentifier> recipients
+            TypingAction action,
+            Set<RecipientIdentifier> recipients
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
         return sendTypingMessage(action.toSignalService(), recipients);
     }
 
     @Override
-    public SendMessageResults sendReadReceipt(
-            RecipientIdentifier.Single sender, List<Long> messageIds
-    ) {
-        final var timestamp = System.currentTimeMillis();
+    public SendMessageResults sendReadReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
+        final var timestamp = getNextMessageTimestamp();
         var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ,
                 messageIds,
                 timestamp);
@@ -589,10 +715,8 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public SendMessageResults sendViewedReceipt(
-            RecipientIdentifier.Single sender, List<Long> messageIds
-    ) {
-        final var timestamp = System.currentTimeMillis();
+    public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
+        final var timestamp = getNextMessageTimestamp();
         var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED,
                 messageIds,
                 timestamp);
@@ -606,8 +730,15 @@ public class ManagerImpl implements Manager {
             final SignalServiceReceiptMessage receiptMessage
     ) {
         try {
-            final var result = context.getSendHelper()
-                    .sendReceiptMessage(receiptMessage, context.getRecipientHelper().resolveRecipient(sender));
+            final var recipientId = context.getRecipientHelper().resolveRecipient(sender);
+            final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
+
+            final var serviceId = account.getRecipientAddressResolver()
+                    .resolveRecipientAddress(recipientId)
+                    .serviceId();
+            if (serviceId.isPresent()) {
+                context.getSyncHelper().sendSyncReceiptMessage(serviceId.get(), receiptMessage);
+            }
             return new SendMessageResults(timestamp, Map.of(sender, List.of(toSendMessageResult(result))));
         } catch (UnregisteredRecipientException e) {
             return new SendMessageResults(timestamp,
@@ -617,7 +748,9 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendMessageResults sendMessage(
-            Message message, Set<RecipientIdentifier> recipients
+            Message message,
+            Set<RecipientIdentifier> recipients,
+            boolean notifySelf
     ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
         final var selfProfile = context.getProfileHelper().getSelfProfile();
         if (selfProfile == null || selfProfile.getDisplayName().isEmpty()) {
@@ -626,37 +759,62 @@ public class ManagerImpl implements Manager {
         }
         final var messageBuilder = SignalServiceDataMessage.newBuilder();
         applyMessage(messageBuilder, message);
-        return sendMessage(messageBuilder, recipients);
+        return sendMessage(messageBuilder, recipients, notifySelf);
     }
 
     @Override
     public SendMessageResults sendEditMessage(
-            Message message, Set<RecipientIdentifier> recipients, long editTargetTimestamp
+            Message message,
+            Set<RecipientIdentifier> recipients,
+            long editTargetTimestamp
     ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
         final var messageBuilder = SignalServiceDataMessage.newBuilder();
         applyMessage(messageBuilder, message);
-        return sendMessage(messageBuilder, recipients, Optional.of(editTargetTimestamp));
+        return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp));
     }
 
     private void applyMessage(
-            final SignalServiceDataMessage.Builder messageBuilder, final Message message
+            final SignalServiceDataMessage.Builder messageBuilder,
+            final Message message
     ) throws AttachmentInvalidException, IOException, UnregisteredRecipientException, InvalidStickerException {
-        if (message.messageText().length() > 2000) {
-            final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
-            final var textAttachment = AttachmentUtils.createAttachmentStream(new StreamDetails(new ByteArrayInputStream(
-                    messageBytes), MimeUtils.LONG_TEXT, messageBytes.length), Optional.empty());
-            messageBuilder.withBody(message.messageText().substring(0, 2000));
-            messageBuilder.withAttachment(context.getAttachmentHelper().uploadAttachment(textAttachment));
+        final var additionalAttachments = new ArrayList<SignalServiceAttachment>();
+        if (Utf8.size(message.messageText()) > MAX_MESSAGE_SIZE_BYTES) {
+            final var result = splitByByteLength(message.messageText(), MAX_MESSAGE_SIZE_BYTES);
+            final var trimmed = result.getFirst();
+            final var remainder = result.getSecond();
+            if (remainder != null) {
+                final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
+                final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
+                final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
+                        MimeUtils.LONG_TEXT,
+                        messageBytes.length);
+                final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
+                        Optional.empty(),
+                        uploadSpec);
+                messageBuilder.withBody(trimmed);
+                additionalAttachments.add(context.getAttachmentHelper().uploadAttachment(textAttachment));
+            } else {
+                messageBuilder.withBody(message.messageText());
+            }
         } else {
             messageBuilder.withBody(message.messageText());
         }
-        if (message.attachments().size() > 0) {
-            messageBuilder.withAttachments(context.getAttachmentHelper().uploadAttachments(message.attachments()));
+        if (!message.attachments().isEmpty()) {
+            final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments());
+            if (!additionalAttachments.isEmpty()) {
+                additionalAttachments.addAll(uploadedAttachments);
+                messageBuilder.withAttachments(additionalAttachments);
+            } else {
+                messageBuilder.withAttachments(uploadedAttachments);
+            }
+        } else if (!additionalAttachments.isEmpty()) {
+            messageBuilder.withAttachments(additionalAttachments);
         }
-        if (message.mentions().size() > 0) {
+        messageBuilder.withViewOnce(message.viewOnce());
+        if (!message.mentions().isEmpty()) {
             messageBuilder.withMentions(resolveMentions(message.mentions()));
         }
-        if (message.textStyles().size() > 0) {
+        if (!message.textStyles().isEmpty()) {
             messageBuilder.withBodyRanges(message.textStyles().stream().map(TextStyle::toBodyRange).toList());
         }
         if (message.quote().isPresent()) {
@@ -696,13 +854,17 @@ public class ManagerImpl implements Manager {
             if (streamDetails == null) {
                 throw new InvalidStickerException("Missing local sticker file");
             }
+            final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
+            final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
+                    Optional.empty(),
+                    uploadSpec);
             messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
                     stickerPack.packKey(),
                     stickerId,
                     manifestSticker.emoji(),
-                    AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty())));
+                    stickerAttachment));
         }
-        if (message.previews().size() > 0) {
+        if (!message.previews().isEmpty()) {
             final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
             for (final var p : message.previews()) {
                 final var image = p.image().isPresent() ? context.getAttachmentHelper()
@@ -738,7 +900,8 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendMessageResults sendRemoteDeleteMessage(
-            long targetSentTimestamp, Set<RecipientIdentifier> recipients
+            long targetSentTimestamp,
+            Set<RecipientIdentifier> recipients
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
         var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
         final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
@@ -746,6 +909,9 @@ public class ManagerImpl implements Manager {
             if (recipient instanceof RecipientIdentifier.Uuid u) {
                 account.getMessageSendLogStore()
                         .deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid()));
+            } else if (recipient instanceof RecipientIdentifier.Pni pni) {
+                account.getMessageSendLogStore()
+                        .deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
             } else if (recipient instanceof RecipientIdentifier.Single r) {
                 try {
                     final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@@ -760,7 +926,7 @@ public class ManagerImpl implements Manager {
                 account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId());
             }
         }
-        return sendMessage(messageBuilder, recipients);
+        return sendMessage(messageBuilder, recipients, false);
     }
 
     @Override
@@ -782,18 +948,20 @@ public class ManagerImpl implements Manager {
             messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
                     targetSentTimestamp));
         }
-        return sendMessage(messageBuilder, recipients);
+        return sendMessage(messageBuilder, recipients, false);
     }
 
     @Override
     public SendMessageResults sendPaymentNotificationMessage(
-            byte[] receipt, String note, RecipientIdentifier.Single recipient
+            byte[] receipt,
+            String note,
+            RecipientIdentifier.Single recipient
     ) throws IOException {
         final var paymentNotification = new SignalServiceDataMessage.PaymentNotification(receipt, note);
         final var payment = new SignalServiceDataMessage.Payment(paymentNotification, null);
         final var messageBuilder = SignalServiceDataMessage.newBuilder().withPayment(payment);
         try {
-            return sendMessage(messageBuilder, Set.of(recipient));
+            return sendMessage(messageBuilder, Set.of(recipient), false);
         } catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) {
             throw new AssertionError(e);
         }
@@ -805,7 +973,8 @@ public class ManagerImpl implements Manager {
 
         try {
             return sendMessage(messageBuilder,
-                    recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()));
+                    recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()),
+                    false);
         } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
             throw new AssertionError(e);
         } finally {
@@ -827,11 +996,58 @@ public class ManagerImpl implements Manager {
         }
     }
 
+    @Override
+    public SendMessageResults sendMessageRequestResponse(
+            final MessageRequestResponse.Type type,
+            final Set<RecipientIdentifier> recipients
+    ) {
+        var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
+        for (final var recipient : recipients) {
+            if (recipient instanceof RecipientIdentifier.NoteToSelf || (
+                    recipient instanceof RecipientIdentifier.Single single
+                            && new RecipientAddress(single.toPartialRecipientAddress()).matches(account.getSelfRecipientAddress())
+            )) {
+                final var result = context.getSyncHelper()
+                        .sendMessageRequestResponse(type, account.getSelfRecipientId());
+                if (result != null) {
+                    results.put(recipient, List.of(toSendMessageResult(result)));
+                }
+                results.put(recipient, List.of(toSendMessageResult(result)));
+            } else if (recipient instanceof RecipientIdentifier.Single single) {
+                try {
+                    final var recipientId = context.getRecipientHelper().resolveRecipient(single);
+                    final var result = context.getSyncHelper().sendMessageRequestResponse(type, recipientId);
+                    if (result != null) {
+                        results.put(recipient, List.of(toSendMessageResult(result)));
+                    }
+                } catch (UnregisteredRecipientException e) {
+                    results.put(recipient,
+                            List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
+                }
+            } else if (recipient instanceof RecipientIdentifier.Group group) {
+                final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId());
+                results.put(recipient, List.of(toSendMessageResult(result)));
+            }
+        }
+        return new SendMessageResults(0, results);
+    }
+
+    @Override
+    public void hideRecipient(final RecipientIdentifier.Single recipient) {
+        final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
+        if (recipientIdOptional.isPresent()) {
+            context.getContactHelper().setContactHidden(recipientIdOptional.get(), true);
+            account.removeRecipient(recipientIdOptional.get());
+            syncRemoteStorage();
+        }
+    }
+
     @Override
     public void deleteRecipient(final RecipientIdentifier.Single recipient) {
         final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
         if (recipientIdOptional.isPresent()) {
             account.removeRecipient(recipientIdOptional.get());
+            syncRemoteStorage();
         }
     }
 
@@ -840,28 +1056,38 @@ public class ManagerImpl implements Manager {
         final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
         if (recipientIdOptional.isPresent()) {
             account.getContactStore().deleteContact(recipientIdOptional.get());
+            syncRemoteStorage();
         }
     }
 
     @Override
     public void setContactName(
-            RecipientIdentifier.Single recipient, String givenName, final String familyName
+            final RecipientIdentifier.Single recipient,
+            final String givenName,
+            final String familyName,
+            final String nickGivenName,
+            final String nickFamilyName,
+            final String note
     ) throws NotPrimaryDeviceException, UnregisteredRecipientException {
         if (!account.isPrimaryDevice()) {
             throw new NotPrimaryDeviceException();
         }
         context.getContactHelper()
-                .setContactName(context.getRecipientHelper().resolveRecipient(recipient), givenName, familyName);
+                .setContactName(context.getRecipientHelper().resolveRecipient(recipient),
+                        givenName,
+                        familyName,
+                        nickGivenName,
+                        nickFamilyName,
+                        note);
+        syncRemoteStorage();
     }
 
     @Override
     public void setContactsBlocked(
-            Collection<RecipientIdentifier.Single> recipients, boolean blocked
-    ) throws NotPrimaryDeviceException, IOException, UnregisteredRecipientException {
-        if (!account.isPrimaryDevice()) {
-            throw new NotPrimaryDeviceException();
-        }
-        if (recipients.size() == 0) {
+            Collection<RecipientIdentifier.Single> recipients,
+            boolean blocked
+    ) throws IOException, UnregisteredRecipientException {
+        if (recipients.isEmpty()) {
             return;
         }
         final var recipientIds = context.getRecipientHelper().resolveRecipients(recipients);
@@ -872,6 +1098,10 @@ public class ManagerImpl implements Manager {
                 continue;
             }
             context.getContactHelper().setContactBlocked(recipientId, blocked);
+            context.getSyncHelper()
+                    .sendMessageRequestResponse(blocked
+                            ? MessageRequestResponse.Type.BLOCK
+                            : MessageRequestResponse.Type.UNBLOCK_AND_ACCEPT, recipientId);
             // if we don't have a common group with the blocked contact we need to rotate the profile key
             shouldRotateProfileKey = blocked && (
                     shouldRotateProfileKey || account.getGroupStore()
@@ -884,16 +1114,15 @@ public class ManagerImpl implements Manager {
             context.getProfileHelper().rotateProfileKey();
         }
         context.getSyncHelper().sendBlockedList();
+        syncRemoteStorage();
     }
 
     @Override
     public void setGroupsBlocked(
-            final Collection<GroupId> groupIds, final boolean blocked
-    ) throws GroupNotFoundException, NotPrimaryDeviceException, IOException {
-        if (!account.isPrimaryDevice()) {
-            throw new NotPrimaryDeviceException();
-        }
-        if (groupIds.size() == 0) {
+            final Collection<GroupId> groupIds,
+            final boolean blocked
+    ) throws GroupNotFoundException, IOException {
+        if (groupIds.isEmpty()) {
             return;
         }
         boolean shouldRotateProfileKey = false;
@@ -902,26 +1131,33 @@ public class ManagerImpl implements Manager {
                 continue;
             }
             context.getGroupHelper().setGroupBlocked(groupId, blocked);
+            context.getSyncHelper()
+                    .sendMessageRequestResponse(blocked
+                            ? MessageRequestResponse.Type.BLOCK
+                            : MessageRequestResponse.Type.UNBLOCK_AND_ACCEPT, groupId);
             shouldRotateProfileKey = blocked;
         }
         if (shouldRotateProfileKey) {
             context.getProfileHelper().rotateProfileKey();
         }
         context.getSyncHelper().sendBlockedList();
+        syncRemoteStorage();
     }
 
     @Override
     public void setExpirationTimer(
-            RecipientIdentifier.Single recipient, int messageExpirationTimer
+            RecipientIdentifier.Single recipient,
+            int messageExpirationTimer
     ) throws IOException, UnregisteredRecipientException {
         var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
         context.getContactHelper().setExpirationTimer(recipientId, messageExpirationTimer);
         final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
         try {
-            sendMessage(messageBuilder, Set.of(recipient));
+            sendMessage(messageBuilder, Set.of(recipient), false);
         } catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) {
             throw new AssertionError(e);
         }
+        syncRemoteStorage();
     }
 
     @Override
@@ -979,13 +1215,13 @@ public class ManagerImpl implements Manager {
     }
 
     @Override
-    public void requestAllSyncData() throws IOException {
+    public void requestAllSyncData() {
         context.getSyncHelper().requestAllSyncData();
-        retrieveRemoteStorage();
+        syncRemoteStorage();
     }
 
-    void retrieveRemoteStorage() throws IOException {
-        context.getStorageHelper().readDataFromStorage();
+    void syncRemoteStorage() {
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
     }
 
     @Override
@@ -1006,7 +1242,7 @@ public class ManagerImpl implements Manager {
         if (receiveThread != null || isReceivingSynchronous) {
             return;
         }
-        receiveThread = new Thread(() -> {
+        receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
             logger.debug("Starting receiving messages");
             context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
             logger.debug("Finished receiving messages");
@@ -1020,9 +1256,6 @@ public class ManagerImpl implements Manager {
                 }
             }
         });
-        receiveThread.setName("receive-" + threadNumber.getAndIncrement());
-
-        receiveThread.start();
     }
 
     private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) {
@@ -1070,19 +1303,38 @@ public class ManagerImpl implements Manager {
             return true;
         }
         synchronized (messageHandlers) {
-            return messageHandlers.size() > 0;
+            return !messageHandlers.isEmpty();
         }
     }
 
     @Override
     public void receiveMessages(
-            Optional<Duration> timeout, Optional<Integer> maxMessages, ReceiveMessageHandler handler
+            Optional<Duration> timeout,
+            Optional<Integer> maxMessages,
+            ReceiveMessageHandler handler
     ) throws IOException, AlreadyReceivingException {
         receiveMessages(timeout.orElse(Duration.ofMinutes(1)), timeout.isPresent(), maxMessages.orElse(null), handler);
     }
 
+    @Override
+    public void stopReceiveMessages() {
+        Thread thread = null;
+        synchronized (messageHandlers) {
+            if (isReceivingSynchronous) {
+                thread = receiveThread;
+                receiveThread = null;
+            }
+        }
+        if (thread != null) {
+            stopReceiveThread(thread);
+        }
+    }
+
     private void receiveMessages(
-            Duration timeout, boolean returnOnTimeout, Integer maxMessages, ReceiveMessageHandler handler
+            Duration timeout,
+            boolean returnOnTimeout,
+            Integer maxMessages,
+            ReceiveMessageHandler handler
     ) throws IOException, AlreadyReceivingException {
         synchronized (messageHandlers) {
             if (isReceiving()) {
@@ -1100,7 +1352,7 @@ public class ManagerImpl implements Manager {
             synchronized (messageHandlers) {
                 receiveThread = null;
                 isReceivingSynchronous = false;
-                if (messageHandlers.size() > 0) {
+                if (!messageHandlers.isEmpty()) {
                     startReceiveThreadIfRequired();
                 }
             }
@@ -1155,7 +1407,8 @@ public class ManagerImpl implements Manager {
                         s.getContact(),
                         s.getProfileKey(),
                         s.getExpiringProfileKeyCredential(),
-                        s.getProfile()))
+                        s.getProfile(),
+                        s.getDiscoverable()))
                 .toList();
     }
 
@@ -1210,7 +1463,7 @@ public class ManagerImpl implements Manager {
         final var scannableFingerprint = context.getIdentityHelper()
                 .computeSafetyNumberForScanning(identityInfo.getServiceId(), identityInfo.getIdentityKey());
         return new Identity(address.toApiRecipientAddress(),
-                identityInfo.getIdentityKey(),
+                identityInfo.getIdentityKey().getPublicKey().serialize(),
                 context.getIdentityHelper()
                         .computeSafetyNumber(identityInfo.getServiceId(), identityInfo.getIdentityKey()),
                 scannableFingerprint == null ? null : scannableFingerprint.getSerialized(),
@@ -1237,20 +1490,18 @@ public class ManagerImpl implements Manager {
 
     @Override
     public boolean trustIdentityVerified(
-            RecipientIdentifier.Single recipient, IdentityVerificationCode verificationCode
+            RecipientIdentifier.Single recipient,
+            IdentityVerificationCode verificationCode
     ) throws UnregisteredRecipientException {
-        if (verificationCode instanceof IdentityVerificationCode.Fingerprint fingerprint) {
-            return trustIdentity(recipient,
+        return switch (verificationCode) {
+            case IdentityVerificationCode.Fingerprint fingerprint -> trustIdentity(recipient,
                     r -> context.getIdentityHelper().trustIdentityVerified(r, fingerprint.fingerprint()));
-        } else if (verificationCode instanceof IdentityVerificationCode.SafetyNumber safetyNumber) {
-            return trustIdentity(recipient,
+            case IdentityVerificationCode.SafetyNumber safetyNumber -> trustIdentity(recipient,
                     r -> context.getIdentityHelper().trustIdentityVerifiedSafetyNumber(r, safetyNumber.safetyNumber()));
-        } else if (verificationCode instanceof IdentityVerificationCode.ScannableSafetyNumber safetyNumber) {
-            return trustIdentity(recipient,
+            case IdentityVerificationCode.ScannableSafetyNumber safetyNumber -> trustIdentity(recipient,
                     r -> context.getIdentityHelper().trustIdentityVerifiedSafetyNumber(r, safetyNumber.safetyNumber()));
-        } else {
-            throw new AssertionError("Invalid verification code type");
-        }
+            case null -> throw new AssertionError("Invalid verification code type");
+        };
     }
 
     @Override
@@ -1259,12 +1510,13 @@ public class ManagerImpl implements Manager {
     }
 
     private boolean trustIdentity(
-            RecipientIdentifier.Single recipient, Function<RecipientId, Boolean> trustMethod
+            RecipientIdentifier.Single recipient,
+            Function<RecipientId, Boolean> trustMethod
     ) throws UnregisteredRecipientException {
         final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
         final var updated = trustMethod.apply(recipientId);
         if (updated && this.isReceiving()) {
-            context.getReceiveHelper().setNeedsToRetryFailedMessages(true);
+            account.setNeedsToRetryFailedMessages(true);
         }
         return updated;
     }
@@ -1288,6 +1540,58 @@ public class ManagerImpl implements Manager {
         return context.getAttachmentHelper().retrieveAttachment(id).getStream();
     }
 
+    @Override
+    public InputStream retrieveContactAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
+        final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
+        final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
+        final var streamDetails = context.getAvatarStore().retrieveContactAvatar(address);
+        if (streamDetails == null) {
+            throw new FileNotFoundException();
+        }
+        return streamDetails.getStream();
+    }
+
+    @Override
+    public InputStream retrieveProfileAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
+        final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
+        context.getProfileHelper().getRecipientProfile(recipientId);
+        final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
+        final var streamDetails = context.getAvatarStore().retrieveProfileAvatar(address);
+        if (streamDetails == null) {
+            throw new FileNotFoundException();
+        }
+        return streamDetails.getStream();
+    }
+
+    @Override
+    public InputStream retrieveGroupAvatar(final GroupId groupId) throws IOException {
+        final var streamDetails = context.getAvatarStore().retrieveGroupAvatar(groupId);
+        context.getGroupHelper().getGroup(groupId);
+        if (streamDetails == null) {
+            throw new FileNotFoundException();
+        }
+        return streamDetails.getStream();
+    }
+
+    @Override
+    public InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException {
+        var streamDetails = context.getStickerPackStore().retrieveSticker(stickerPackId, stickerId);
+        if (streamDetails == null) {
+            final var pack = account.getStickerStore().getStickerPack(stickerPackId);
+            if (pack != null) {
+                try {
+                    context.getStickerHelper().retrieveStickerPack(stickerPackId, pack.packKey());
+                } catch (InvalidMessageException e) {
+                    logger.warn("Failed to download sticker pack");
+                }
+            }
+        }
+        if (streamDetails == null) {
+            throw new FileNotFoundException();
+        }
+        return streamDetails.getStream();
+    }
+
     @Override
     public void close() {
         Thread thread;
@@ -1300,9 +1604,12 @@ public class ManagerImpl implements Manager {
         if (thread != null) {
             stopReceiveThread(thread);
         }
-        executor.shutdown();
+        context.close();
+        executor.close();
 
-        dependencies.getSignalWebSocket().disconnect();
+        dependencies.getAuthenticatedSignalWebSocket().disconnect();
+        dependencies.getUnauthenticatedSignalWebSocket().disconnect();
+        dependencies.getPushServiceSocket().close();
         disposable.dispose();
 
         if (account != null) {