X-Git-Url: https://git.nmode.ca/signal-cli/blobdiff_plain/5427fa0132d04a23f459d490f8e9a0ed33730d2e..b810e303ec9d0fcc3ba948b7e65d57f85bffe437:/lib/src/main/java/org/asamk/signal/manager/Manager.java diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index f9d86ce6..327e876d 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -16,19 +16,27 @@ */ package org.asamk.signal.manager; +import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceEnvironment; import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupIdV1; import org.asamk.signal.manager.groups.GroupInviteLinkUrl; +import org.asamk.signal.manager.groups.GroupLinkState; import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.GroupPermission; import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.groups.LastGroupAdminException; import org.asamk.signal.manager.groups.NotAGroupMemberException; -import org.asamk.signal.manager.helper.GroupHelper; +import org.asamk.signal.manager.helper.GroupV2Helper; import org.asamk.signal.manager.helper.PinHelper; import org.asamk.signal.manager.helper.ProfileHelper; import org.asamk.signal.manager.helper.UnidentifiedAccessHelper; +import org.asamk.signal.manager.jobs.Context; +import org.asamk.signal.manager.jobs.Job; +import org.asamk.signal.manager.jobs.RetrieveStickerPackJob; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.groups.GroupInfo; import org.asamk.signal.manager.storage.groups.GroupInfoV1; @@ -57,14 +65,12 @@ import org.signal.libsignal.metadata.ProtocolLegacyMessageException; import org.signal.libsignal.metadata.ProtocolNoSessionException; import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; import org.signal.libsignal.metadata.SelfSendException; -import org.signal.libsignal.metadata.certificate.CertificateValidator; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.groups.GroupSecretParams; -import org.signal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.slf4j.Logger; @@ -78,17 +84,12 @@ import org.whispersystems.libsignal.state.PreKeyRecord; import org.whispersystems.libsignal.state.SignedPreKeyRecord; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.SignalServiceMessagePipe; -import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; -import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; +import org.whispersystems.signalservice.api.InvalidMessageStructureException; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; -import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; -import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; @@ -100,6 +101,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; @@ -108,7 +110,6 @@ import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsO import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroup; import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsInputStream; import org.whispersystems.signalservice.api.messages.multidevice.DeviceGroupsOutputStream; -import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; @@ -117,13 +118,14 @@ import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.api.util.InvalidNumberException; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -import org.whispersystems.signalservice.api.util.SleepTimer; -import org.whispersystems.signalservice.api.util.UptimeSleepTimer; import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException; import org.whispersystems.signalservice.internal.contacts.crypto.Quote; import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; @@ -160,6 +162,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import java.util.stream.Collectors; @@ -169,29 +172,29 @@ public class Manager implements Closeable { private final static Logger logger = LoggerFactory.getLogger(Manager.class); - private final CertificateValidator certificateValidator; - private final ServiceEnvironmentConfig serviceEnvironmentConfig; - private final String userAgent; + private final SignalDependencies dependencies; private SignalAccount account; - private final SignalServiceAccountManager accountManager; - private final GroupsV2Api groupsV2Api; - private final GroupsV2Operations groupsV2Operations; - private final SignalServiceMessageReceiver messageReceiver; - private final ClientZkProfileOperations clientZkProfileOperations; private final ExecutorService executor = Executors.newCachedThreadPool(); - private SignalServiceMessagePipe messagePipe = null; - private SignalServiceMessagePipe unidentifiedMessagePipe = null; - private final UnidentifiedAccessHelper unidentifiedAccessHelper; private final ProfileHelper profileHelper; - private final GroupHelper groupHelper; + private final GroupV2Helper groupV2Helper; private final PinHelper pinHelper; private final AvatarStore avatarStore; private final AttachmentStore attachmentStore; + private final StickerPackStore stickerPackStore; + private final SignalSessionLock sessionLock = new SignalSessionLock() { + private final ReentrantLock LEGACY_LOCK = new ReentrantLock(); + + @Override + public Lock acquire() { + LEGACY_LOCK.lock(); + return LEGACY_LOCK::unlock; + } + }; Manager( SignalAccount account, @@ -201,42 +204,19 @@ public class Manager implements Closeable { ) { this.account = account; this.serviceEnvironmentConfig = serviceEnvironmentConfig; - this.certificateValidator = new CertificateValidator(serviceEnvironmentConfig.getUnidentifiedSenderTrustRoot()); - this.userAgent = userAgent; - this.groupsV2Operations = capabilities.isGv2() ? new GroupsV2Operations(ClientZkOperations.create( - serviceEnvironmentConfig.getSignalServiceConfiguration())) : null; - final SleepTimer timer = new UptimeSleepTimer(); - this.accountManager = new SignalServiceAccountManager(serviceEnvironmentConfig.getSignalServiceConfiguration(), - new DynamicCredentialsProvider(account.getUuid(), - account.getUsername(), - account.getPassword(), - account.getDeviceId()), - userAgent, - groupsV2Operations, - ServiceConfig.AUTOMATIC_NETWORK_RETRY, - timer); - this.groupsV2Api = accountManager.getGroupsV2Api(); - final var keyBackupService = accountManager.getKeyBackupService(ServiceConfig.getIasKeyStore(), - serviceEnvironmentConfig.getKeyBackupConfig().getEnclaveName(), - serviceEnvironmentConfig.getKeyBackupConfig().getServiceId(), - serviceEnvironmentConfig.getKeyBackupConfig().getMrenclave(), - 10); - - this.pinHelper = new PinHelper(keyBackupService); - this.clientZkProfileOperations = capabilities.isGv2() - ? ClientZkOperations.create(serviceEnvironmentConfig.getSignalServiceConfiguration()) - .getProfileOperations() - : null; - this.messageReceiver = new SignalServiceMessageReceiver(serviceEnvironmentConfig.getSignalServiceConfiguration(), - account.getUuid(), + + final var credentialsProvider = new DynamicCredentialsProvider(account.getUuid(), account.getUsername(), account.getPassword(), - account.getDeviceId(), + account.getDeviceId()); + this.dependencies = new SignalDependencies(account.getSelfAddress(), + serviceEnvironmentConfig, userAgent, - null, - timer, - clientZkProfileOperations, - ServiceConfig.AUTOMATIC_NETWORK_RETRY); + credentialsProvider, + account.getSignalProtocolStore(), + executor, + sessionLock); + this.pinHelper = new PinHelper(dependencies.getKeyBackupService()); this.unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey, account.getProfileStore()::getProfileKey, @@ -244,18 +224,19 @@ public class Manager implements Closeable { this::getSenderCertificate); this.profileHelper = new ProfileHelper(account.getProfileStore()::getProfileKey, unidentifiedAccessHelper::getAccessFor, - unidentified -> unidentified ? getOrCreateUnidentifiedMessagePipe() : getOrCreateMessagePipe(), - () -> messageReceiver, + dependencies::getProfileService, + dependencies::getMessageReceiver, this::resolveSignalServiceAddress); - this.groupHelper = new GroupHelper(this::getRecipientProfileKeyCredential, + this.groupV2Helper = new GroupV2Helper(this::getRecipientProfileKeyCredential, this::getRecipientProfile, account::getSelfRecipientId, - groupsV2Operations, - groupsV2Api, + dependencies.getGroupsV2Operations(), + dependencies.getGroupsV2Api(), this::getGroupAuthForToday, this::resolveSignalServiceAddress); this.avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); this.attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); + this.stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath()); } public String getUsername() { @@ -287,7 +268,7 @@ public class Manager implements Closeable { throw new NotRegisteredException(); } - var account = SignalAccount.load(pathConfig.getDataPath(), username); + var account = SignalAccount.load(pathConfig.getDataPath(), username, true); if (!account.isRegistered()) { throw new NotRegisteredException(); @@ -315,11 +296,22 @@ public class Manager implements Closeable { } public void checkAccountState() throws IOException { - if (accountManager.getPreKeysCount() < ServiceConfig.PREKEY_MINIMUM_COUNT) { + if (account.getLastReceiveTimestamp() == 0) { + logger.warn("The Signal protocol expects that incoming messages are regularly received."); + } else { + var diffInMilliseconds = System.currentTimeMillis() - account.getLastReceiveTimestamp(); + long days = TimeUnit.DAYS.convert(diffInMilliseconds, TimeUnit.MILLISECONDS); + if (days > 7) { + logger.warn( + "Messages have been last received {} days ago. The Signal protocol expects that incoming messages are regularly received.", + days); + } + } + if (dependencies.getAccountManager().getPreKeysCount() < ServiceConfig.PREKEY_MINIMUM_COUNT) { refreshPreKeys(); } if (account.getUuid() == null) { - account.setUuid(accountManager.getOwnUuid()); + account.setUuid(dependencies.getAccountManager().getOwnUuid()); } updateAccountAttributes(); } @@ -341,31 +333,37 @@ public class Manager implements Closeable { } public void updateAccountAttributes() throws IOException { - accountManager.setAccountAttributes(null, - account.getLocalRegistrationId(), - true, - // set legacy pin only if no KBS master key is set - account.getPinMasterKey() == null ? account.getRegistrationLockPin() : null, - account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(), - account.getSelfUnidentifiedAccessKey(), - account.isUnrestrictedUnidentifiedAccess(), - capabilities, - account.isDiscoverableByPhoneNumber()); + dependencies.getAccountManager() + .setAccountAttributes(account.getEncryptedDeviceName(), + null, + account.getLocalRegistrationId(), + true, + // set legacy pin only if no KBS master key is set + account.getPinMasterKey() == null ? account.getRegistrationLockPin() : null, + account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(), + account.getSelfUnidentifiedAccessKey(), + account.isUnrestrictedUnidentifiedAccess(), + capabilities, + account.isDiscoverableByPhoneNumber()); } /** - * @param name if null, the previous name will be kept + * @param givenName if null, the previous givenName will be kept + * @param familyName if null, the previous familyName will be kept * @param about if null, the previous about text will be kept * @param aboutEmoji if null, the previous about emoji will be kept * @param avatar if avatar is null the image from the local avatar store is used (if present), - * if it's Optional.absent(), the avatar will be removed */ - public void setProfile(String name, String about, String aboutEmoji, Optional avatar) throws IOException { + public void setProfile( + String givenName, final String familyName, String about, String aboutEmoji, Optional avatar + ) throws IOException { var profile = getRecipientProfile(account.getSelfRecipientId()); var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile); - if (name != null) { - builder.withGivenName(name); - builder.withFamilyName(null); + if (givenName != null) { + builder.withGivenName(givenName); + } + if (familyName != null) { + builder.withFamilyName(familyName); } if (about != null) { builder.withAbout(about); @@ -378,12 +376,14 @@ public class Manager implements Closeable { try (final var streamDetails = avatar == null ? avatarStore.retrieveProfileAvatar(getSelfAddress()) : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) { - accountManager.setVersionedProfile(account.getUuid(), - account.getProfileKey(), - newProfile.getInternalServiceName(), - newProfile.getAbout() == null ? "" : newProfile.getAbout(), - newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(), - streamDetails); + dependencies.getAccountManager() + .setVersionedProfile(account.getUuid(), + account.getProfileKey(), + newProfile.getInternalServiceName(), + newProfile.getAbout() == null ? "" : newProfile.getAbout(), + newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(), + Optional.absent(), + streamDetails); } if (avatar != null) { @@ -406,26 +406,37 @@ public class Manager implements Closeable { // When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false. // If this is the master device, other users can't send messages to this number anymore. // If this is a linked device, other users can still send messages, but this device doesn't receive them anymore. - accountManager.setGcmId(Optional.absent()); + dependencies.getAccountManager().setGcmId(Optional.absent()); account.setRegistered(false); } public void deleteAccount() throws IOException { - accountManager.deleteAccount(); + dependencies.getAccountManager().deleteAccount(); account.setRegistered(false); } - public List getLinkedDevices() throws IOException { - var devices = accountManager.getDevices(); + public List getLinkedDevices() throws IOException { + var devices = dependencies.getAccountManager().getDevices(); account.setMultiDevice(devices.size() > 1); - return devices; + var identityKey = account.getIdentityKeyPair().getPrivateKey(); + return devices.stream().map(d -> { + String deviceName = d.getName(); + if (deviceName != null) { + try { + deviceName = DeviceNameUtil.decryptDeviceName(deviceName, identityKey); + } catch (IOException e) { + logger.debug("Failed to decrypt device name, maybe plain text?", e); + } + } + return new Device(d.getId(), deviceName, d.getCreated(), d.getLastSeen()); + }).collect(Collectors.toList()); } public void removeLinkedDevices(int deviceId) throws IOException { - accountManager.removeDevice(deviceId); - var devices = accountManager.getDevices(); + dependencies.getAccountManager().removeDevice(deviceId); + var devices = dependencies.getAccountManager().getDevices(); account.setMultiDevice(devices.size() > 1); } @@ -437,13 +448,14 @@ public class Manager implements Closeable { private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException { var identityKeyPair = getIdentityKeyPair(); - var verificationCode = accountManager.getNewDeviceVerificationCode(); - - accountManager.addDevice(deviceIdentifier, - deviceKey, - identityKeyPair, - Optional.of(account.getProfileKey().serialize()), - verificationCode); + var verificationCode = dependencies.getAccountManager().getNewDeviceVerificationCode(); + + dependencies.getAccountManager() + .addDevice(deviceIdentifier, + deviceKey, + identityKeyPair, + Optional.of(account.getProfileKey().serialize()), + verificationCode); account.setMultiDevice(true); } @@ -461,7 +473,7 @@ public class Manager implements Closeable { account.setRegistrationLockPin(pin.get(), masterKey); } else { // Remove legacy registration lock - accountManager.removeRegistrationLockV1(); + dependencies.getAccountManager().removeRegistrationLockV1(); // Remove KBS Pin pinHelper.removeRegistrationLockPin(); @@ -475,7 +487,7 @@ public class Manager implements Closeable { final var identityKeyPair = getIdentityKeyPair(); var signedPreKeyRecord = generateSignedPreKey(identityKeyPair); - accountManager.setPreKeys(identityKeyPair.getPublicKey(), signedPreKeyRecord, oneTimePreKeys); + dependencies.getAccountManager().setPreKeys(identityKeyPair.getPublicKey(), signedPreKeyRecord, oneTimePreKeys); } private List generatePreKeys() { @@ -496,44 +508,6 @@ public class Manager implements Closeable { return record; } - private SignalServiceMessagePipe getOrCreateMessagePipe() { - if (messagePipe == null) { - messagePipe = messageReceiver.createMessagePipe(); - } - return messagePipe; - } - - private SignalServiceMessagePipe getOrCreateUnidentifiedMessagePipe() { - if (unidentifiedMessagePipe == null) { - unidentifiedMessagePipe = messageReceiver.createUnidentifiedMessagePipe(); - } - return unidentifiedMessagePipe; - } - - private SignalServiceMessageSender createMessageSender() { - return new SignalServiceMessageSender(serviceEnvironmentConfig.getSignalServiceConfiguration(), - account.getUuid(), - account.getUsername(), - account.getPassword(), - account.getDeviceId(), - account.getSignalProtocolStore(), - userAgent, - account.isMultiDevice(), - Optional.fromNullable(messagePipe), - Optional.fromNullable(unidentifiedMessagePipe), - Optional.absent(), - clientZkProfileOperations, - executor, - ServiceConfig.MAX_ENVELOPE_SIZE, - ServiceConfig.AUTOMATIC_NETWORK_RETRY); - } - - public Profile getRecipientProfile( - SignalServiceAddress address - ) { - return getRecipientProfile(resolveRecipient(address), false); - } - public Profile getRecipientProfile( RecipientId recipientId ) { @@ -545,17 +519,9 @@ public class Manager implements Closeable { Profile getRecipientProfile( RecipientId recipientId, boolean force ) { - var profileKey = account.getProfileStore().getProfileKey(recipientId); - if (profileKey == null) { - if (force) { - // retrieve profile to get identity key - retrieveEncryptedProfile(recipientId); - } - return null; - } var profile = account.getProfileStore().getProfile(recipientId); - var now = new Date().getTime(); + var now = System.currentTimeMillis(); // Profiles are cached for 24h before retrieving them again, unless forced if (!force && profile != null && now - profile.getLastUpdateTimestamp() < 24 * 60 * 60 * 1000) { return profile; @@ -579,12 +545,29 @@ public class Manager implements Closeable { return null; } - profile = decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile); + profile = decryptProfileIfKeyKnown(recipientId, encryptedProfile); account.getProfileStore().storeProfile(recipientId, profile); return profile; } + private Profile decryptProfileIfKeyKnown( + final RecipientId recipientId, final SignalServiceProfile encryptedProfile + ) { + var profileKey = account.getProfileStore().getProfileKey(recipientId); + if (profileKey == null) { + return new Profile(System.currentTimeMillis(), + null, + null, + null, + null, + ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null), + ProfileUtils.getCapabilities(encryptedProfile)); + } + + return decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile); + } + private SignalServiceProfile retrieveEncryptedProfile(RecipientId recipientId) { try { return retrieveProfileAndCredential(recipientId, SignalServiceProfile.RequestType.PROFILE).getProfile(); @@ -611,7 +594,7 @@ public class Manager implements Closeable { } } catch (InvalidKeyException ignored) { logger.warn("Got invalid identity key in profile for {}", - resolveSignalServiceAddress(recipientId).getLegacyIdentifier()); + resolveSignalServiceAddress(recipientId).getIdentifier()); } return profileAndCredential; } @@ -713,9 +696,10 @@ public class Manager implements Closeable { public Pair> sendGroupMessageReaction( String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, GroupId groupId ) throws IOException, InvalidNumberException, NotAGroupMemberException, GroupNotFoundException { + var targetAuthorRecipientId = canonicalizeAndResolveRecipient(targetAuthor); var reaction = new SignalServiceDataMessage.Reaction(emoji, remove, - canonicalizeAndResolveSignalServiceAddress(targetAuthor), + resolveSignalServiceAddress(targetAuthorRecipientId), targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); @@ -733,115 +717,204 @@ public class Manager implements Closeable { return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfRecipientId())); } - public Pair> sendQuitGroupMessage(GroupId groupId) throws GroupNotFoundException, IOException, NotAGroupMemberException { - SignalServiceDataMessage.Builder messageBuilder; + public Pair> sendQuitGroupMessage( + GroupId groupId, Set groupAdmins + ) throws GroupNotFoundException, IOException, NotAGroupMemberException, InvalidNumberException, LastGroupAdminException { + var group = getGroupForUpdating(groupId); + if (group instanceof GroupInfoV1) { + return quitGroupV1((GroupInfoV1) group); + } - final var g = getGroupForUpdating(groupId); - if (g instanceof GroupInfoV1) { - var groupInfoV1 = (GroupInfoV1) g; - var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT).withId(groupId.serialize()).build(); - messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group); - groupInfoV1.removeMember(account.getSelfRecipientId()); - account.getGroupStore().updateGroup(groupInfoV1); - } else { - final var groupInfoV2 = (GroupInfoV2) g; - final var groupGroupChangePair = groupHelper.leaveGroup(groupInfoV2); - groupInfoV2.setGroup(groupGroupChangePair.first(), this::resolveRecipient); - messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); - account.getGroupStore().updateGroup(groupInfoV2); + final var newAdmins = getSignalServiceAddresses(groupAdmins); + try { + return quitGroupV2((GroupInfoV2) group, newAdmins); + } catch (ConflictException e) { + // Detected conflicting update, refreshing group and trying again + group = getGroup(groupId, true); + return quitGroupV2((GroupInfoV2) group, newAdmins); } + } - return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfRecipientId())); + private Pair> quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT) + .withId(groupInfoV1.getGroupId().serialize()) + .build(); + + var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group); + groupInfoV1.removeMember(account.getSelfRecipientId()); + account.getGroupStore().updateGroup(groupInfoV1); + return sendMessage(messageBuilder, groupInfoV1.getMembersWithout(account.getSelfRecipientId())); } - public Pair> updateGroup( - GroupId groupId, String name, List members, File avatarFile - ) throws IOException, GroupNotFoundException, AttachmentInvalidException, InvalidNumberException, NotAGroupMemberException { - return sendUpdateGroupMessage(groupId, - name, - members == null ? null : getSignalServiceAddresses(members), - avatarFile); + private Pair> quitGroupV2( + final GroupInfoV2 groupInfoV2, final Set newAdmins + ) throws LastGroupAdminException, IOException { + final var currentAdmins = groupInfoV2.getAdminMembers(); + newAdmins.removeAll(currentAdmins); + newAdmins.retainAll(groupInfoV2.getMembers()); + if (currentAdmins.contains(getSelfRecipientId()) + && currentAdmins.size() == 1 + && groupInfoV2.getMembers().size() > 1 + && newAdmins.size() == 0) { + // Last admin can't leave the group, unless she's also the last member + throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle()); + } + final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins); + groupInfoV2.setGroup(groupGroupChangePair.first(), this::resolveRecipient); + var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); + account.getGroupStore().updateGroup(groupInfoV2); + return sendMessage(messageBuilder, groupInfoV2.getMembersWithout(account.getSelfRecipientId())); } - private Pair> sendUpdateGroupMessage( - GroupId groupId, String name, Set members, File avatarFile - ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { - GroupInfo g; + public void deleteGroup(GroupId groupId) throws IOException { + account.getGroupStore().deleteGroup(groupId); + avatarStore.deleteGroupAvatar(groupId); + } + + public Pair> createGroup( + String name, List members, File avatarFile + ) throws IOException, AttachmentInvalidException, InvalidNumberException { + return createGroup(name, members == null ? null : getSignalServiceAddresses(members), avatarFile); + } + + private Pair> createGroup( + String name, Set members, File avatarFile + ) throws IOException, AttachmentInvalidException { + final var selfRecipientId = account.getSelfRecipientId(); + if (members != null && members.contains(selfRecipientId)) { + members = new HashSet<>(members); + members.remove(selfRecipientId); + } + + var gv2Pair = groupV2Helper.createGroup(name == null ? "" : name, + members == null ? Set.of() : members, + avatarFile); + SignalServiceDataMessage.Builder messageBuilder; - if (groupId == null) { - // Create new group - var gv2Pair = groupHelper.createGroupV2(name == null ? "" : name, - members == null ? Set.of() : members, - avatarFile); - if (gv2Pair == null) { - var gv1 = new GroupInfoV1(GroupIdV1.createRandom()); - gv1.addMembers(List.of(account.getSelfRecipientId())); - updateGroupV1(gv1, name, members, avatarFile); - messageBuilder = getGroupUpdateMessageBuilder(gv1); - g = gv1; - } else { - final var gv2 = gv2Pair.first(); - final var decryptedGroup = gv2Pair.second(); + if (gv2Pair == null) { + // Failed to create v2 group, creating v1 group instead + var gv1 = new GroupInfoV1(GroupIdV1.createRandom()); + gv1.addMembers(List.of(selfRecipientId)); + final var result = updateGroupV1(gv1, name, members, avatarFile); + return new Pair<>(gv1.getGroupId(), result.second()); + } - gv2.setGroup(decryptedGroup, this::resolveRecipient); - if (avatarFile != null) { - avatarStore.storeGroupAvatar(gv2.getGroupId(), - outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); - } - messageBuilder = getGroupUpdateMessageBuilder(gv2, null); - g = gv2; - } - } else { - var group = getGroupForUpdating(groupId); - if (group instanceof GroupInfoV2) { - final var groupInfoV2 = (GroupInfoV2) group; - - Pair> result = null; - if (groupInfoV2.isPendingMember(account.getSelfRecipientId())) { - var groupGroupChangePair = groupHelper.acceptInvite(groupInfoV2); - result = sendUpdateGroupMessage(groupInfoV2, - groupGroupChangePair.first(), - groupGroupChangePair.second()); - } + final var gv2 = gv2Pair.first(); + final var decryptedGroup = gv2Pair.second(); - if (members != null) { - final var newMembers = new HashSet<>(members); - newMembers.removeAll(group.getMembers()); - if (newMembers.size() > 0) { - var groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2, newMembers); - result = sendUpdateGroupMessage(groupInfoV2, - groupGroupChangePair.first(), - groupGroupChangePair.second()); - } - } - if (result == null || name != null || avatarFile != null) { - var groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2, name, avatarFile); - if (avatarFile != null) { - avatarStore.storeGroupAvatar(groupInfoV2.getGroupId(), - outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); - } - result = sendUpdateGroupMessage(groupInfoV2, - groupGroupChangePair.first(), - groupGroupChangePair.second()); - } + gv2.setGroup(decryptedGroup, this::resolveRecipient); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(gv2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + messageBuilder = getGroupUpdateMessageBuilder(gv2, null); + account.getGroupStore().updateGroup(gv2); + + final var result = sendMessage(messageBuilder, gv2.getMembersIncludingPendingWithout(selfRecipientId)); + return new Pair<>(gv2.getGroupId(), result.second()); + } + + public Pair> updateGroup( + GroupId groupId, + String name, + String description, + List members, + List removeMembers, + List admins, + List removeAdmins, + boolean resetGroupLink, + GroupLinkState groupLinkState, + GroupPermission addMemberPermission, + GroupPermission editDetailsPermission, + File avatarFile, + Integer expirationTimer + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, InvalidNumberException, NotAGroupMemberException { + return updateGroup(groupId, + name, + description, + members == null ? null : getSignalServiceAddresses(members), + removeMembers == null ? null : getSignalServiceAddresses(removeMembers), + admins == null ? null : getSignalServiceAddresses(admins), + removeAdmins == null ? null : getSignalServiceAddresses(removeAdmins), + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer); + } + + private Pair> updateGroup( + final GroupId groupId, + final String name, + final String description, + final Set members, + final Set removeMembers, + final Set admins, + final Set removeAdmins, + final boolean resetGroupLink, + final GroupLinkState groupLinkState, + final GroupPermission addMemberPermission, + final GroupPermission editDetailsPermission, + final File avatarFile, + final Integer expirationTimer + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { + var group = getGroupForUpdating(groupId); - return new Pair<>(group.getGroupId(), result.second()); - } else { - var gv1 = (GroupInfoV1) group; - updateGroupV1(gv1, name, members, avatarFile); - messageBuilder = getGroupUpdateMessageBuilder(gv1); - g = gv1; + if (group instanceof GroupInfoV2) { + try { + return updateGroupV2((GroupInfoV2) group, + name, + description, + members, + removeMembers, + admins, + removeAdmins, + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer); + } catch (ConflictException e) { + // Detected conflicting update, refreshing group and trying again + group = getGroup(groupId, true); + return updateGroupV2((GroupInfoV2) group, + name, + description, + members, + removeMembers, + admins, + removeAdmins, + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer); } } - account.getGroupStore().updateGroup(g); + final var gv1 = (GroupInfoV1) group; + final var result = updateGroupV1(gv1, name, members, avatarFile); + if (expirationTimer != null) { + setExpirationTimer(gv1, expirationTimer); + } + return result; + } + + private Pair> updateGroupV1( + final GroupInfoV1 gv1, final String name, final Set members, final File avatarFile + ) throws IOException, AttachmentInvalidException { + updateGroupV1Details(gv1, name, members, avatarFile); + var messageBuilder = getGroupUpdateMessageBuilder(gv1); + + account.getGroupStore().updateGroup(gv1); - final var result = sendMessage(messageBuilder, - g.getMembersIncludingPendingWithout(account.getSelfRecipientId())); - return new Pair<>(g.getGroupId(), result.second()); + return sendMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId())); } - private void updateGroupV1( + private void updateGroupV1Details( final GroupInfoV1 g, final String name, final Collection members, final File avatarFile ) throws IOException { if (name != null) { @@ -879,18 +952,123 @@ public class Manager implements Closeable { } } - public Pair> joinGroup( - GroupInviteLinkUrl inviteLinkUrl - ) throws IOException, GroupLinkNotActiveException { - return sendJoinGroupMessage(inviteLinkUrl); + private Pair> updateGroupV2( + final GroupInfoV2 group, + final String name, + final String description, + final Set members, + final Set removeMembers, + final Set admins, + final Set removeAdmins, + final boolean resetGroupLink, + final GroupLinkState groupLinkState, + final GroupPermission addMemberPermission, + final GroupPermission editDetailsPermission, + final File avatarFile, + Integer expirationTimer + ) throws IOException { + Pair> result = null; + if (group.isPendingMember(account.getSelfRecipientId())) { + var groupGroupChangePair = groupV2Helper.acceptInvite(group); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (members != null) { + final var newMembers = new HashSet<>(members); + newMembers.removeAll(group.getMembers()); + if (newMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + } + + if (removeMembers != null) { + var existingRemoveMembers = new HashSet<>(removeMembers); + existingRemoveMembers.retainAll(group.getMembers()); + existingRemoveMembers.remove(getSelfRecipientId());// self can be removed with sendQuitGroupMessage + if (existingRemoveMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + var pendingRemoveMembers = new HashSet<>(removeMembers); + pendingRemoveMembers.retainAll(group.getPendingMembers()); + if (pendingRemoveMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + } + + if (admins != null) { + final var newAdmins = new HashSet<>(admins); + newAdmins.retainAll(group.getMembers()); + newAdmins.removeAll(group.getAdminMembers()); + if (newAdmins.size() > 0) { + for (var admin : newAdmins) { + var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); + result = sendUpdateGroupV2Message(group, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + } + } + + if (removeAdmins != null) { + final var existingRemoveAdmins = new HashSet<>(removeAdmins); + existingRemoveAdmins.retainAll(group.getAdminMembers()); + if (existingRemoveAdmins.size() > 0) { + for (var admin : existingRemoveAdmins) { + var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); + result = sendUpdateGroupV2Message(group, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + } + } + + if (resetGroupLink) { + var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (groupLinkState != null) { + var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (addMemberPermission != null) { + var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (editDetailsPermission != null) { + var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (expirationTimer != null) { + var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (name != null || description != null || avatarFile != null) { + var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(group.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + return result; } - private Pair> sendJoinGroupMessage( + public Pair> joinGroup( GroupInviteLinkUrl inviteLinkUrl ) throws IOException, GroupLinkNotActiveException { - final var groupJoinInfo = groupHelper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), + final var groupJoinInfo = groupV2Helper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword()); - final var groupChange = groupHelper.joinGroup(inviteLinkUrl.getGroupMasterKey(), + final var groupChange = groupV2Helper.joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo); final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), @@ -902,11 +1080,24 @@ public class Manager implements Closeable { return new Pair<>(group.getGroupId(), List.of()); } - final var result = sendUpdateGroupMessage(group, group.getGroup(), groupChange); + final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange); return new Pair<>(group.getGroupId(), result.second()); } + private Pair> sendUpdateGroupV2Message( + GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange + ) throws IOException { + final var selfRecipientId = account.getSelfRecipientId(); + final var members = group.getMembersIncludingPendingWithout(selfRecipientId); + group.setGroup(newDecryptedGroup, this::resolveRecipient); + members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); + + final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray()); + account.getGroupStore().updateGroup(group); + return sendMessage(messageBuilder, members); + } + private static int currentTimeDays() { return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); } @@ -916,28 +1107,20 @@ public class Manager implements Closeable { ) throws IOException { final var today = currentTimeDays(); // Returns credentials for the next 7 days - final var credentials = groupsV2Api.getCredentials(today); + final var credentials = dependencies.getGroupsV2Api().getCredentials(today); // TODO cache credentials until they expire var authCredentialResponse = credentials.get(today); try { - return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), - today, - groupSecretParams, - authCredentialResponse); + return dependencies.getGroupsV2Api() + .getGroupsV2AuthorizationString(account.getUuid(), + today, + groupSecretParams, + authCredentialResponse); } catch (VerificationFailedException e) { throw new IOException(e); } } - private Pair> sendUpdateGroupMessage( - GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange - ) throws IOException { - group.setGroup(newDecryptedGroup, this::resolveRecipient); - final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray()); - account.getGroupStore().updateGroup(group); - return sendMessage(messageBuilder, group.getMembersIncludingPendingWithout(account.getSelfRecipientId())); - } - Pair> sendGroupInfoMessage( GroupIdV1 groupId, SignalServiceAddress recipient ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException { @@ -948,14 +1131,15 @@ public class Manager implements Closeable { } g = (GroupInfoV1) group; - if (!g.isMember(resolveRecipient(recipient))) { + final var recipientId = resolveRecipient(recipient); + if (!g.isMember(recipientId)) { throw new NotAGroupMemberException(groupId, g.name); } var messageBuilder = getGroupUpdateMessageBuilder(g); // Send group message only to the recipient who requested it - return sendMessage(messageBuilder, Set.of(resolveRecipient(recipient))); + return sendMessage(messageBuilder, Set.of(recipientId)); } private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException { @@ -1008,9 +1192,10 @@ public class Manager implements Closeable { List.of(messageId), System.currentTimeMillis()); - createMessageSender().sendReceipt(remoteAddress, - unidentifiedAccessHelper.getAccessFor(resolveRecipient(remoteAddress)), - receiptMessage); + dependencies.getMessageSender() + .sendReceipt(remoteAddress, + unidentifiedAccessHelper.getAccessFor(resolveRecipient(remoteAddress)), + receiptMessage); } public Pair> sendMessage( @@ -1021,7 +1206,7 @@ public class Manager implements Closeable { var attachmentStreams = AttachmentUtils.getSignalServiceAttachments(attachments); // Upload attachments here, so we only upload once even for multiple recipients - var messageSender = createMessageSender(); + var messageSender = dependencies.getMessageSender(); var attachmentPointers = new ArrayList(attachmentStreams.size()); for (var attachment : attachmentStreams) { if (attachment.isStream()) { @@ -1065,9 +1250,10 @@ public class Manager implements Closeable { public Pair> sendMessageReaction( String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, List recipients ) throws IOException, InvalidNumberException { + var targetAuthorRecipientId = canonicalizeAndResolveRecipient(targetAuthor); var reaction = new SignalServiceDataMessage.Reaction(emoji, remove, - canonicalizeAndResolveSignalServiceAddress(targetAuthor), + resolveSignalServiceAddress(targetAuthorRecipientId), targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); return sendMessage(messageBuilder, getSignalServiceAddresses(recipients)); @@ -1087,9 +1273,11 @@ public class Manager implements Closeable { } } - public String getContactName(String number) throws InvalidNumberException { - var contact = account.getContactStore().getContact(canonicalizeAndResolveRecipient(number)); - return contact == null || contact.getName() == null ? "" : contact.getName(); + void renewSession(RecipientId recipientId) throws IOException { + account.getSessionStore().archiveSessions(recipientId); + if (!recipientId.equals(getSelfRecipientId())) { + sendNullMessage(recipientId); + } } public void setContactName(String number, String name) throws InvalidNumberException, NotMasterDeviceException { @@ -1156,15 +1344,17 @@ public class Manager implements Closeable { /** * Change the expiration timer for a group */ - public void setExpirationTimer(GroupId groupId, int messageExpirationTimer) { - var g = getGroup(groupId); - if (g instanceof GroupInfoV1) { - var groupInfoV1 = (GroupInfoV1) g; - groupInfoV1.messageExpirationTime = messageExpirationTimer; - account.getGroupStore().updateGroup(groupInfoV1); - } else { - throw new RuntimeException("TODO Not implemented!"); - } + private void setExpirationTimer( + GroupInfoV1 groupInfoV1, int messageExpirationTimer + ) throws NotAGroupMemberException, GroupNotFoundException, IOException { + groupInfoV1.messageExpirationTime = messageExpirationTimer; + account.getGroupStore().updateGroup(groupInfoV1); + sendExpirationTimerUpdate(groupInfoV1.getGroupId()); + } + + private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); + sendGroupMessage(messageBuilder, groupId); } /** @@ -1176,21 +1366,23 @@ public class Manager implements Closeable { public String uploadStickerPack(File path) throws IOException, StickerPackInvalidException { var manifest = StickerUtils.getSignalServiceStickerManifestUpload(path); - var messageSender = createMessageSender(); + var messageSender = dependencies.getMessageSender(); var packKey = KeyUtils.createStickerUploadKey(); - var packId = messageSender.uploadStickerManifest(manifest, packKey); + var packIdString = messageSender.uploadStickerManifest(manifest, packKey); + var packId = StickerPackId.deserialize(Hex.fromStringCondensed(packIdString)); - var sticker = new Sticker(StickerPackId.deserialize(Hex.fromStringCondensed(packId)), packKey); + var sticker = new Sticker(packId, packKey); account.getStickerStore().updateSticker(sticker); try { return new URI("https", "signal.art", "/addstickers/", - "pack_id=" + URLEncoder.encode(packId, StandardCharsets.UTF_8) + "&pack_key=" + URLEncoder.encode( - Hex.toStringCondensed(packKey), - StandardCharsets.UTF_8)).toString(); + "pack_id=" + + URLEncoder.encode(Hex.toStringCondensed(packId.serialize()), StandardCharsets.UTF_8) + + "&pack_key=" + + URLEncoder.encode(Hex.toStringCondensed(packKey), StandardCharsets.UTF_8)).toString(); } catch (URISyntaxException e) { throw new AssertionError(e); } @@ -1265,11 +1457,13 @@ public class Manager implements Closeable { } private byte[] getSenderCertificate() { - // TODO support UUID capable sender certificates - // byte[] certificate = accountManager.getSenderCertificateForPhoneNumberPrivacy(); byte[] certificate; try { - certificate = accountManager.getSenderCertificate(); + if (account.isPhoneNumberShared()) { + certificate = dependencies.getAccountManager().getSenderCertificate(); + } else { + certificate = dependencies.getAccountManager().getSenderCertificateForPhoneNumberPrivacy(); + } } catch (IOException e) { logger.warn("Failed to get sender certificate, ignoring: {}", e.getMessage()); return null; @@ -1279,8 +1473,8 @@ public class Manager implements Closeable { } private void sendSyncMessage(SignalServiceSyncMessage message) throws IOException, UntrustedIdentityException { - var messageSender = createMessageSender(); - messageSender.sendMessage(message, unidentifiedAccessHelper.getAccessForSync()); + var messageSender = dependencies.getMessageSender(); + messageSender.sendSyncMessage(message, unidentifiedAccessHelper.getAccessForSync()); } private Set getSignalServiceAddresses(Collection numbers) throws InvalidNumberException { @@ -1288,7 +1482,7 @@ public class Manager implements Closeable { final var addressesMissingUuid = new HashSet(); for (var number : numbers) { - final var resolvedAddress = canonicalizeAndResolveSignalServiceAddress(number); + final var resolvedAddress = resolveSignalServiceAddress(canonicalizeAndResolveRecipient(number)); if (resolvedAddress.getUuid().isPresent()) { signalServiceAddresses.add(resolvedAddress); } else { @@ -1334,36 +1528,73 @@ public class Manager implements Closeable { private Map getRegisteredUsers(final Set numbers) throws IOException { try { - return accountManager.getRegisteredUsers(ServiceConfig.getIasKeyStore(), - numbers, - serviceEnvironmentConfig.getCdsMrenclave()); + return dependencies.getAccountManager() + .getRegisteredUsers(ServiceConfig.getIasKeyStore(), + numbers, + serviceEnvironmentConfig.getCdsMrenclave()); } catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | SignatureException | UnauthenticatedResponseException | InvalidKeyException e) { throw new IOException(e); } } + public void sendTypingMessage( + TypingAction action, Set recipients + ) throws IOException, UntrustedIdentityException, InvalidNumberException { + sendTypingMessageInternal(action, getSignalServiceAddresses(recipients)); + } + + private void sendTypingMessageInternal( + TypingAction action, Set recipientIds + ) throws IOException, UntrustedIdentityException { + final var timestamp = System.currentTimeMillis(); + var message = new SignalServiceTypingMessage(action.toSignalService(), timestamp, Optional.absent()); + var messageSender = dependencies.getMessageSender(); + for (var recipientId : recipientIds) { + final var address = resolveSignalServiceAddress(recipientId); + messageSender.sendTyping(address, unidentifiedAccessHelper.getAccessFor(recipientId), message); + } + } + + public void sendGroupTypingMessage( + TypingAction action, GroupId groupId + ) throws IOException, NotAGroupMemberException, GroupNotFoundException { + final var timestamp = System.currentTimeMillis(); + final var g = getGroupForSending(groupId); + final var message = new SignalServiceTypingMessage(action.toSignalService(), + timestamp, + Optional.of(groupId.serialize())); + final var messageSender = dependencies.getMessageSender(); + final var recipientIdList = new ArrayList<>(g.getMembersWithout(account.getSelfRecipientId())); + final var addresses = recipientIdList.stream() + .map(this::resolveSignalServiceAddress) + .collect(Collectors.toList()); + messageSender.sendTyping(addresses, unidentifiedAccessHelper.getAccessFor(recipientIdList), message, null); + } + private Pair> sendMessage( SignalServiceDataMessage.Builder messageBuilder, Set recipientIds ) throws IOException { final var timestamp = System.currentTimeMillis(); messageBuilder.withTimestamp(timestamp); - getOrCreateMessagePipe(); - getOrCreateUnidentifiedMessagePipe(); + SignalServiceDataMessage message = null; try { message = messageBuilder.build(); if (message.getGroupContext().isPresent()) { try { - var messageSender = createMessageSender(); + var messageSender = dependencies.getMessageSender(); final var isRecipientUpdate = false; final var recipientIdList = new ArrayList<>(recipientIds); final var addresses = recipientIdList.stream() .map(this::resolveSignalServiceAddress) .collect(Collectors.toList()); - var result = messageSender.sendMessage(addresses, + var result = messageSender.sendDataMessage(addresses, unidentifiedAccessHelper.getAccessFor(recipientIdList), isRecipientUpdate, - message); + ContentHint.DEFAULT, + message, + sendResult -> logger.trace("Partial message send result: {}", sendResult.isSuccess()), + () -> false); for (var r : result) { if (r.getIdentityFailure() != null) { @@ -1389,7 +1620,7 @@ public class Manager implements Closeable { final var expirationTime = contact != null ? contact.getMessageExpirationTime() : 0; messageBuilder.withExpiration(expirationTime); message = messageBuilder.build(); - results.add(sendMessage(resolveSignalServiceAddress(recipientId), message)); + results.add(sendMessage(recipientId, message)); } return new Pair<>(timestamp, results); } @@ -1407,8 +1638,6 @@ public class Manager implements Closeable { ) throws IOException { final var timestamp = System.currentTimeMillis(); messageBuilder.withTimestamp(timestamp); - getOrCreateMessagePipe(); - getOrCreateUnidentifiedMessagePipe(); final var recipientId = account.getSelfRecipientId(); final var contact = account.getContactStore().getContact(recipientId); @@ -1421,11 +1650,12 @@ public class Manager implements Closeable { } private SendMessageResult sendSelfMessage(SignalServiceDataMessage message) throws IOException { - var messageSender = createMessageSender(); + var messageSender = dependencies.getMessageSender(); - var recipient = account.getSelfAddress(); + var recipientId = account.getSelfRecipientId(); - final var unidentifiedAccess = unidentifiedAccessHelper.getAccessFor(resolveRecipient(recipient)); + final var unidentifiedAccess = unidentifiedAccessHelper.getAccessFor(recipientId); + var recipient = resolveSignalServiceAddress(recipientId); var transcript = new SentTranscriptMessage(Optional.of(recipient), message.getTimestamp(), message, @@ -1435,30 +1665,29 @@ public class Manager implements Closeable { var syncMessage = SignalServiceSyncMessage.forSentTranscript(transcript); try { - var startTime = System.currentTimeMillis(); - messageSender.sendMessage(syncMessage, unidentifiedAccess); - return SendMessageResult.success(recipient, - unidentifiedAccess.isPresent(), - false, - System.currentTimeMillis() - startTime); + return messageSender.sendSyncMessage(syncMessage, unidentifiedAccess); } catch (UntrustedIdentityException e) { return SendMessageResult.identityFailure(recipient, e.getIdentityKey()); } } private SendMessageResult sendMessage( - SignalServiceAddress address, SignalServiceDataMessage message + RecipientId recipientId, SignalServiceDataMessage message ) throws IOException { - var messageSender = createMessageSender(); + var messageSender = dependencies.getMessageSender(); - final var recipientId = resolveRecipient(address); + final var address = resolveSignalServiceAddress(recipientId); try { try { - return messageSender.sendMessage(address, unidentifiedAccessHelper.getAccessFor(recipientId), message); + return messageSender.sendDataMessage(address, + unidentifiedAccessHelper.getAccessFor(recipientId), + ContentHint.DEFAULT, + message); } catch (UnregisteredUserException e) { final var newRecipientId = refreshRegisteredUser(recipientId); - return messageSender.sendMessage(resolveSignalServiceAddress(newRecipientId), + return messageSender.sendDataMessage(resolveSignalServiceAddress(newRecipientId), unidentifiedAccessHelper.getAccessFor(newRecipientId), + ContentHint.DEFAULT, message); } } catch (UntrustedIdentityException e) { @@ -1466,20 +1695,27 @@ public class Manager implements Closeable { } } - private SignalServiceContent decryptMessage(SignalServiceEnvelope envelope) throws InvalidMetadataMessageException, ProtocolInvalidMessageException, ProtocolDuplicateMessageException, ProtocolLegacyMessageException, ProtocolInvalidKeyIdException, InvalidMetadataVersionException, ProtocolInvalidVersionException, ProtocolNoSessionException, ProtocolInvalidKeyException, SelfSendException, UnsupportedDataMessageException, org.whispersystems.libsignal.UntrustedIdentityException { - var cipher = new SignalServiceCipher(account.getSelfAddress(), - account.getSignalProtocolStore(), - certificateValidator); + private SendMessageResult sendNullMessage(RecipientId recipientId) throws IOException { + var messageSender = dependencies.getMessageSender(); + + final var address = resolveSignalServiceAddress(recipientId); try { - return cipher.decrypt(envelope); - } catch (ProtocolUntrustedIdentityException e) { - if (e.getCause() instanceof org.whispersystems.libsignal.UntrustedIdentityException) { - throw (org.whispersystems.libsignal.UntrustedIdentityException) e.getCause(); + try { + return messageSender.sendNullMessage(address, unidentifiedAccessHelper.getAccessFor(recipientId)); + } catch (UnregisteredUserException e) { + final var newRecipientId = refreshRegisteredUser(recipientId); + final var newAddress = resolveSignalServiceAddress(newRecipientId); + return messageSender.sendNullMessage(newAddress, unidentifiedAccessHelper.getAccessFor(newRecipientId)); } - throw new AssertionError(e); + } catch (UntrustedIdentityException e) { + return SendMessageResult.identityFailure(address, e.getIdentityKey()); } } + private SignalServiceContent decryptMessage(SignalServiceEnvelope envelope) throws InvalidMetadataMessageException, ProtocolInvalidMessageException, ProtocolDuplicateMessageException, ProtocolLegacyMessageException, ProtocolInvalidKeyIdException, InvalidMetadataVersionException, ProtocolInvalidVersionException, ProtocolNoSessionException, ProtocolInvalidKeyException, SelfSendException, UnsupportedDataMessageException, ProtocolUntrustedIdentityException, InvalidMessageStructureException { + return dependencies.getCipher().decrypt(envelope); + } + private void handleEndSession(RecipientId recipientId) { account.getSessionStore().deleteAllSessions(recipientId); } @@ -1631,6 +1867,7 @@ public class Manager implements Closeable { sticker = new Sticker(stickerPackId, messageSticker.getPackKey()); account.getStickerStore().updateSticker(sticker); } + enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey())); } return actions; } @@ -1661,10 +1898,12 @@ public class Manager implements Closeable { if (signedGroupChange != null && groupInfoV2.getGroup() != null && groupInfoV2.getGroup().getRevision() + 1 == revision) { - group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), signedGroupChange, groupMasterKey); + group = groupV2Helper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), + signedGroupChange, + groupMasterKey); } if (group == null) { - group = groupHelper.getDecryptedGroup(groupSecretParams); + group = groupV2Helper.getDecryptedGroup(groupSecretParams); } if (group != null) { storeProfileKeysFromMembers(group); @@ -1682,11 +1921,11 @@ public class Manager implements Closeable { private void storeProfileKeysFromMembers(final DecryptedGroup group) { for (var member : group.getMembersList()) { - final var address = resolveRecipient(new SignalServiceAddress(UuidUtil.parseOrThrow(member.getUuid() - .toByteArray()), null)); + final var uuid = UuidUtil.parseOrThrow(member.getUuid().toByteArray()); + final var recipientId = account.getRecipientStore().resolveRecipient(uuid); try { account.getProfileStore() - .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray())); + .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray())); } catch (InvalidInputException ignored) { } } @@ -1704,6 +1943,9 @@ public class Manager implements Closeable { try { action.execute(this); } catch (Throwable e) { + if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } logger.warn("Message action failed.", e); } } @@ -1721,10 +1963,10 @@ public class Manager implements Closeable { if (!envelope.isReceipt()) { try { content = decryptMessage(envelope); - } catch (org.whispersystems.libsignal.UntrustedIdentityException e) { + } catch (ProtocolUntrustedIdentityException e) { if (!envelope.hasSource()) { - final var recipientId = resolveRecipient(((org.whispersystems.libsignal.UntrustedIdentityException) e) - .getName()); + final var identifier = e.getSender(); + final var recipientId = resolveRecipient(identifier); try { account.getMessageCache().replaceSender(cachedMessage, recipientId); } catch (IOException ioException) { @@ -1750,28 +1992,32 @@ public class Manager implements Closeable { boolean returnOnTimeout, boolean ignoreAttachments, ReceiveMessageHandler handler - ) throws IOException { + ) throws IOException, InterruptedException { retryFailedReceivedMessages(handler, ignoreAttachments); Set queuedActions = null; - final var messagePipe = getOrCreateMessagePipe(); + final var signalWebSocket = dependencies.getSignalWebSocket(); + signalWebSocket.connect(); var hasCaughtUpWithOldMessages = false; - while (true) { + while (!Thread.interrupted()) { SignalServiceEnvelope envelope; SignalServiceContent content = null; Exception exception = null; final CachedMessage[] cachedMessage = {null}; + account.setLastReceiveTimestamp(System.currentTimeMillis()); + logger.debug("Checking for new message from server"); try { - var result = messagePipe.readOrEmpty(timeout, unit, envelope1 -> { + var result = signalWebSocket.readOrEmpty(unit.toMillis(timeout), envelope1 -> { final var recipientId = envelope1.hasSource() ? resolveRecipient(envelope1.getSourceIdentifier()) : null; // store message on disk, before acknowledging receipt to the server cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId); }); + logger.debug("New message received from server"); if (result.isPresent()) { envelope = result.get(); } else { @@ -1783,6 +2029,9 @@ public class Manager implements Closeable { try { action.execute(this); } catch (Throwable e) { + if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } logger.warn("Message action failed.", e); } } @@ -1793,6 +2042,16 @@ public class Manager implements Closeable { // Continue to wait another timeout for new messages continue; } + } catch (AssertionError e) { + if (e.getCause() instanceof InterruptedException) { + throw (InterruptedException) e.getCause(); + } else { + throw e; + } + } catch (WebSocketUnavailableException e) { + logger.debug("Pipe unexpectedly unavailable, connecting"); + signalWebSocket.connect(); + continue; } catch (TimeoutException e) { if (returnOnTimeout) return; continue; @@ -1800,6 +2059,7 @@ public class Manager implements Closeable { if (envelope.hasSource()) { // Store uuid if we don't have it already + // address/uuid in envelope is sent by server resolveRecipientTrusted(envelope.getSourceAddress()); } final var notAGroupMember = isNotAGroupMember(envelope, content); @@ -1809,12 +2069,25 @@ public class Manager implements Closeable { } catch (Exception e) { exception = e; } + if (!envelope.hasSource() && content != null) { + // Store uuid if we don't have it already + // address/uuid is validated by unidentified sender certificate + resolveRecipientTrusted(content.getSender()); + } var actions = handleMessage(envelope, content, ignoreAttachments); + if (exception instanceof ProtocolInvalidMessageException) { + final var sender = resolveRecipient(((ProtocolInvalidMessageException) exception).getSender()); + logger.debug("Received invalid message, queuing renew session action."); + actions.add(new RenewSessionAction(sender)); + } if (hasCaughtUpWithOldMessages) { for (var action : actions) { try { action.execute(this); } catch (Throwable e) { + if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } logger.warn("Message action failed.", e); } } @@ -1833,9 +2106,9 @@ public class Manager implements Closeable { handler.handleMessage(envelope, content, exception); } if (cachedMessage[0] != null) { - if (exception instanceof org.whispersystems.libsignal.UntrustedIdentityException) { - final var recipientId = resolveRecipient(((org.whispersystems.libsignal.UntrustedIdentityException) exception) - .getName()); + if (exception instanceof ProtocolUntrustedIdentityException) { + final var identifier = ((ProtocolUntrustedIdentityException) exception).getSender(); + final var recipientId = resolveRecipient(identifier); queuedActions.add(new RetrieveProfileAction(recipientId)); if (!envelope.hasSource()) { try { @@ -1980,7 +2253,16 @@ public class Manager implements Closeable { try (var attachmentAsStream = retrieveAttachmentAsStream(groupsMessage.asPointer(), tmpFile)) { var s = new DeviceGroupsInputStream(attachmentAsStream); DeviceGroup g; - while ((g = s.read()) != null) { + while (true) { + try { + g = s.read(); + } catch (IOException e) { + logger.warn("Sync groups contained invalid group, ignoring: {}", e.getMessage()); + continue; + } + if (g == null) { + break; + } var syncGroup = account.getGroupStore().getOrCreateGroupV1(GroupId.v1(g.getId())); if (syncGroup != null) { if (g.getName().isPresent()) { @@ -2051,7 +2333,17 @@ public class Manager implements Closeable { .asPointer(), tmpFile)) { var s = new DeviceContactsInputStream(attachmentAsStream); DeviceContact c; - while ((c = s.read()) != null) { + while (true) { + try { + c = s.read(); + } catch (IOException e) { + logger.warn("Sync contacts contained invalid contact, ignoring: {}", + e.getMessage()); + continue; + } + if (c == null) { + break; + } if (c.getAddress().matches(account.getSelfAddress()) && c.getProfileKey().isPresent()) { account.setProfileKey(c.getProfileKey().get()); } @@ -2118,16 +2410,23 @@ public class Manager implements Closeable { continue; } final var stickerPackId = StickerPackId.deserialize(m.getPackId().get()); + final var installed = !m.getType().isPresent() + || m.getType().get() == StickerPackOperationMessage.Type.INSTALL; + var sticker = account.getStickerStore().getSticker(stickerPackId); - if (sticker == null) { - if (!m.getPackKey().isPresent()) { - continue; + if (m.getPackKey().isPresent()) { + if (sticker == null) { + sticker = new Sticker(stickerPackId, m.getPackKey().get()); + } + if (installed) { + enqueueJob(new RetrieveStickerPackJob(stickerPackId, m.getPackKey().get())); } - sticker = new Sticker(stickerPackId, m.getPackKey().get()); } - sticker.setInstalled(!m.getType().isPresent() - || m.getType().get() == StickerPackOperationMessage.Type.INSTALL); - account.getStickerStore().updateSticker(sticker); + + if (sticker != null) { + sticker.setInstalled(installed); + account.getStickerStore().updateSticker(sticker); + } } } if (syncMessage.getFetchType().isPresent()) { @@ -2185,6 +2484,9 @@ public class Manager implements Closeable { avatarStore.storeProfileAvatar(address, outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream)); } catch (Throwable e) { + if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage()); } } @@ -2220,12 +2522,11 @@ public class Manager implements Closeable { private void retrieveGroupV2Avatar( GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream ) throws IOException { - var groupOperations = groupsV2Operations.forGroup(groupSecretParams); + var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams); var tmpFile = IOUtils.createTempFile(); - try (InputStream input = messageReceiver.retrieveGroupsV2ProfileAvatar(cdnKey, - tmpFile, - ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { + try (InputStream input = dependencies.getMessageReceiver() + .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { var encryptedData = IOUtils.readFully(input); var decryptedData = groupOperations.decryptAvatar(encryptedData); @@ -2245,10 +2546,11 @@ public class Manager implements Closeable { String avatarPath, ProfileKey profileKey, OutputStream outputStream ) throws IOException { var tmpFile = IOUtils.createTempFile(); - try (var input = messageReceiver.retrieveProfileAvatar(avatarPath, - tmpFile, - profileKey, - ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { + try (var input = dependencies.getMessageReceiver() + .retrieveProfileAvatar(avatarPath, + tmpFile, + profileKey, + ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ... IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); } finally { @@ -2296,7 +2598,8 @@ public class Manager implements Closeable { private InputStream retrieveAttachmentAsStream( SignalServiceAttachmentPointer pointer, File tmpFile ) throws IOException, InvalidMessageException, MissingConfigurationException { - return messageReceiver.retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE); + return dependencies.getMessageReceiver() + .retrieveAttachment(pointer, tmpFile, ServiceConfig.MAX_ATTACHMENT_SIZE); } void sendGroups() throws IOException, UntrustedIdentityException { @@ -2462,10 +2765,14 @@ public class Manager implements Closeable { } public GroupInfo getGroup(GroupId groupId) { + return getGroup(groupId, false); + } + + public GroupInfo getGroup(GroupId groupId, boolean forceUpdate) { final var group = account.getGroupStore().getGroup(groupId); - if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) { + if (group instanceof GroupInfoV2 && (forceUpdate || ((GroupInfoV2) group).getGroup() == null)) { final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey()); - ((GroupInfoV2) group).setGroup(groupHelper.getDecryptedGroup(groupSecretParams), this::resolveRecipient); + ((GroupInfoV2) group).setGroup(groupV2Helper.getDecryptedGroup(groupSecretParams), this::resolveRecipient); account.getGroupStore().updateGroup(group); } return group; @@ -2550,14 +2857,6 @@ public class Manager implements Closeable { theirIdentityKey); } - @Deprecated - public SignalServiceAddress canonicalizeAndResolveSignalServiceAddress(String identifier) throws InvalidNumberException { - var canonicalizedNumber = UuidUtil.isUuid(identifier) - ? identifier - : PhoneNumberFormatter.formatNumber(identifier, account.getUsername()); - return resolveSignalServiceAddress(canonicalizedNumber); - } - @Deprecated public SignalServiceAddress resolveSignalServiceAddress(String identifier) { var address = Utils.getSignalServiceAddressFromIdentifier(identifier); @@ -2600,6 +2899,14 @@ public class Manager implements Closeable { return account.getRecipientStore().resolveRecipientTrusted(address); } + private void enqueueJob(Job job) { + var context = new Context(account, + dependencies.getAccountManager(), + dependencies.getMessageReceiver(), + stickerPackStore); + job.run(context); + } + @Override public void close() throws IOException { close(true); @@ -2608,15 +2915,7 @@ public class Manager implements Closeable { void close(boolean closeAccount) throws IOException { executor.shutdown(); - if (messagePipe != null) { - messagePipe.shutdown(); - messagePipe = null; - } - - if (unidentifiedMessagePipe != null) { - unidentifiedMessagePipe.shutdown(); - unidentifiedMessagePipe = null; - } + dependencies.getSignalWebSocket().disconnect(); if (closeAccount && account != null) { account.close();