diff --git a/.gitignore b/.gitignore index 18b11c03..1749973c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,4 @@ local.properties .settings/ out/ .DS_Store -.asciidoctorconfig.adoc -patches/ -signal-cli /bin/ diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index dc6c910e..6e528805 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -14,7 +14,7 @@ repositories { } dependencies { - api("com.github.turasa:signal-service-java:2.15.3_unofficial_27") + api("com.github.turasa:signal-service-java:2.15.3_unofficial_28") implementation("com.google.protobuf:protobuf-javalite:3.10.0") implementation("org.bouncycastle:bcprov-jdk15on:1.69") implementation("org.slf4j:slf4j-api:1.7.30") 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 05700379..7a421966 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -1,23 +1,8 @@ -/* - Copyright (C) 2015-2021 AsamK and contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - */ package org.asamk.signal.manager; -import org.asamk.signal.manager.actions.HandleAction; import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; @@ -25,7 +10,6 @@ import org.asamk.signal.manager.api.SendMessageResults; 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.GroupInviteLinkUrl; import org.asamk.signal.manager.groups.GroupLinkState; @@ -34,241 +18,52 @@ import org.asamk.signal.manager.groups.GroupPermission; import org.asamk.signal.manager.groups.GroupSendingNotAllowedException; import org.asamk.signal.manager.groups.LastGroupAdminException; import org.asamk.signal.manager.groups.NotAGroupMemberException; -import org.asamk.signal.manager.helper.AttachmentHelper; -import org.asamk.signal.manager.helper.ContactHelper; -import org.asamk.signal.manager.helper.GroupHelper; -import org.asamk.signal.manager.helper.GroupV2Helper; -import org.asamk.signal.manager.helper.IncomingMessageHandler; -import org.asamk.signal.manager.helper.PinHelper; -import org.asamk.signal.manager.helper.PreKeyHelper; -import org.asamk.signal.manager.helper.ProfileHelper; -import org.asamk.signal.manager.helper.SendHelper; -import org.asamk.signal.manager.helper.StorageHelper; -import org.asamk.signal.manager.helper.SyncHelper; -import org.asamk.signal.manager.helper.UnidentifiedAccessHelper; -import org.asamk.signal.manager.jobs.Context; 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.identities.TrustNewIdentity; -import org.asamk.signal.manager.storage.messageCache.CachedMessage; import org.asamk.signal.manager.storage.recipients.Contact; import org.asamk.signal.manager.storage.recipients.Profile; -import org.asamk.signal.manager.storage.recipients.RecipientId; -import org.asamk.signal.manager.storage.stickers.Sticker; -import org.asamk.signal.manager.storage.stickers.StickerPackId; -import org.asamk.signal.manager.util.KeyUtils; -import org.asamk.signal.manager.util.StickerUtils; -import org.asamk.signal.manager.util.Utils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; -import org.whispersystems.libsignal.ecc.ECPublicKey; -import org.whispersystems.libsignal.fingerprint.Fingerprint; -import org.whispersystems.libsignal.fingerprint.FingerprintParsingException; -import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; import org.whispersystems.signalservice.api.messages.SignalServiceContent; -import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; -import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; -import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; -import org.whispersystems.signalservice.api.util.DeviceNameUtil; -import org.whispersystems.signalservice.api.util.InvalidNumberException; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -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; -import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider; -import org.whispersystems.signalservice.internal.util.Hex; -import org.whispersystems.signalservice.internal.util.Util; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.net.URI; -import java.net.URISyntaxException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.SignatureException; import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; -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; -import static org.asamk.signal.manager.config.ServiceConfig.capabilities; +public interface Manager extends Closeable { -public class Manager implements Closeable { - - private final static Logger logger = LoggerFactory.getLogger(Manager.class); - - private final ServiceEnvironmentConfig serviceEnvironmentConfig; - private final SignalDependencies dependencies; - - private SignalAccount account; - - private final ExecutorService executor = Executors.newCachedThreadPool(); - - private final ProfileHelper profileHelper; - private final PinHelper pinHelper; - private final StorageHelper storageHelper; - private final SendHelper sendHelper; - private final SyncHelper syncHelper; - private final AttachmentHelper attachmentHelper; - private final GroupHelper groupHelper; - private final ContactHelper contactHelper; - private final IncomingMessageHandler incomingMessageHandler; - private final PreKeyHelper preKeyHelper; - - private final Context context; - private boolean hasCaughtUpWithOldMessages = false; - - Manager( - SignalAccount account, - PathConfig pathConfig, - ServiceEnvironmentConfig serviceEnvironmentConfig, - String userAgent - ) { - this.account = account; - this.serviceEnvironmentConfig = serviceEnvironmentConfig; - - final var credentialsProvider = new DynamicCredentialsProvider(account.getUuid(), - account.getUsername(), - account.getPassword(), - account.getDeviceId()); - final var sessionLock = new SignalSessionLock() { - private final ReentrantLock LEGACY_LOCK = new ReentrantLock(); - - @Override - public Lock acquire() { - LEGACY_LOCK.lock(); - return LEGACY_LOCK::unlock; - } - }; - this.dependencies = new SignalDependencies(serviceEnvironmentConfig, - userAgent, - credentialsProvider, - account.getSignalProtocolStore(), - executor, - sessionLock); - final var avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); - final var attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); - final var stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath()); - - this.attachmentHelper = new AttachmentHelper(dependencies, attachmentStore); - this.pinHelper = new PinHelper(dependencies.getKeyBackupService()); - final var unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey, - account.getProfileStore()::getProfileKey, - this::getRecipientProfile, - this::getSenderCertificate); - this.profileHelper = new ProfileHelper(account, - dependencies, - avatarStore, - account.getProfileStore()::getProfileKey, - unidentifiedAccessHelper::getAccessFor, - this::resolveSignalServiceAddress); - final GroupV2Helper groupV2Helper = new GroupV2Helper(profileHelper::getRecipientProfileKeyCredential, - this::getRecipientProfile, - account::getSelfRecipientId, - dependencies.getGroupsV2Operations(), - dependencies.getGroupsV2Api(), - this::resolveSignalServiceAddress); - this.sendHelper = new SendHelper(account, - dependencies, - unidentifiedAccessHelper, - this::resolveSignalServiceAddress, - account.getRecipientStore(), - this::handleIdentityFailure, - this::getGroup, - this::refreshRegisteredUser); - this.groupHelper = new GroupHelper(account, - dependencies, - attachmentHelper, - sendHelper, - groupV2Helper, - avatarStore, - this::resolveSignalServiceAddress, - account.getRecipientStore()); - this.storageHelper = new StorageHelper(account, dependencies, groupHelper); - this.contactHelper = new ContactHelper(account); - this.syncHelper = new SyncHelper(account, - attachmentHelper, - sendHelper, - groupHelper, - avatarStore, - this::resolveSignalServiceAddress); - preKeyHelper = new PreKeyHelper(account, dependencies); - - this.context = new Context(account, - dependencies, - stickerPackStore, - sendHelper, - groupHelper, - syncHelper, - profileHelper, - storageHelper, - preKeyHelper); - var jobExecutor = new JobExecutor(context); - - this.incomingMessageHandler = new IncomingMessageHandler(account, - dependencies, - account.getRecipientStore(), - this::resolveSignalServiceAddress, - groupHelper, - contactHelper, - attachmentHelper, - syncHelper, - this::getRecipientProfile, - jobExecutor); - } - - public String getUsername() { - return account.getUsername(); - } - - public RecipientId getSelfRecipientId() { - return account.getSelfRecipientId(); - } - - public int getDeviceId() { - return account.getDeviceId(); - } - - public static Manager init( - String username, + static Manager init( + String number, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent, - final TrustNewIdentity trustNewIdentity + TrustNewIdentity trustNewIdentity ) throws IOException, NotRegisteredException { var pathConfig = PathConfig.createDefault(settingsPath); - if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) { + if (!SignalAccount.userExists(pathConfig.getDataPath(), number)) { throw new NotRegisteredException(); } - var account = SignalAccount.load(pathConfig.getDataPath(), username, true, trustNewIdentity); + var account = SignalAccount.load(pathConfig.getDataPath(), number, true, trustNewIdentity); if (!account.isRegistered()) { throw new NotRegisteredException(); @@ -276,10 +71,10 @@ public class Manager implements Closeable { final var serviceEnvironmentConfig = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent); - return new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent); + return new ManagerImpl(account, pathConfig, serviceEnvironmentConfig, userAgent); } - public static List getAllLocalUsernames(File settingsPath) { + static List getAllLocalNumbers(File settingsPath) { var pathConfig = PathConfig.createDefault(settingsPath); final var dataPath = pathConfig.getDataPath(); final var files = dataPath.listFiles(); @@ -295,208 +90,54 @@ public class Manager implements Closeable { .collect(Collectors.toList()); } - public void checkAccountState() throws IOException { - if (account.getLastReceiveTimestamp() == 0) { - logger.info("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); - } - } - preKeyHelper.refreshPreKeysIfNecessary(); - if (account.getUuid() == null) { - account.setUuid(dependencies.getAccountManager().getOwnUuid()); - } - updateAccountAttributes(null); - } + String getSelfNumber(); - /** - * This is used for checking a set of phone numbers for registration on Signal - * - * @param numbers The set of phone number in question - * @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null. - * @throws IOException if its unable to get the contacts to check if they're registered - */ - public Map> areUsersRegistered(Set numbers) throws IOException { - Map canonicalizedNumbers = numbers.stream().collect(Collectors.toMap(n -> n, n -> { - try { - return PhoneNumberFormatter.formatNumber(n, account.getUsername()); - } catch (InvalidNumberException e) { - return ""; - } - })); + void checkAccountState() throws IOException; - // Note "registeredUsers" has no optionals. It only gives us info on users who are registered - var registeredUsers = getRegisteredUsers(canonicalizedNumbers.values() - .stream() - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet())); + Map> areUsersRegistered(Set numbers) throws IOException; - return numbers.stream().collect(Collectors.toMap(n -> n, n -> { - final var number = canonicalizedNumbers.get(n); - final var uuid = registeredUsers.get(number); - return new Pair<>(number.isEmpty() ? null : number, uuid); - })); - } + void updateAccountAttributes(String deviceName) throws IOException; - public void updateAccountAttributes(String deviceName) throws IOException { - final String encryptedDeviceName; - if (deviceName == null) { - encryptedDeviceName = account.getEncryptedDeviceName(); - } else { - final var privateKey = account.getIdentityKeyPair().getPrivateKey(); - encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); - account.setEncryptedDeviceName(encryptedDeviceName); - } - dependencies.getAccountManager() - .setAccountAttributes(encryptedDeviceName, - null, - account.getLocalRegistrationId(), - true, - null, - account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(), - account.getSelfUnidentifiedAccessKey(), - account.isUnrestrictedUnidentifiedAccess(), - capabilities, - account.isDiscoverableByPhoneNumber()); - } + void updateConfiguration( + final Boolean readReceipts, + final Boolean unidentifiedDeliveryIndicators, + final Boolean typingIndicators, + final Boolean linkPreviews + ) throws IOException, NotMasterDeviceException; - /** - * @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), - */ - public void setProfile( - String givenName, final String familyName, String about, String aboutEmoji, Optional avatar - ) throws IOException { - profileHelper.setProfile(givenName, familyName, about, aboutEmoji, avatar); - syncHelper.sendSyncFetchProfileMessage(); - } + void setProfile( + String givenName, String familyName, String about, String aboutEmoji, Optional avatar + ) throws IOException; - public void unregister() throws IOException { - // 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. - dependencies.getAccountManager().setGcmId(Optional.absent()); + void unregister() throws IOException; - account.setRegistered(false); - } + void deleteAccount() throws IOException; - public void deleteAccount() throws IOException { - try { - pinHelper.removeRegistrationLockPin(); - } catch (UnauthenticatedResponseException e) { - logger.warn("Failed to remove registration lock pin"); - } - account.setRegistrationLockPin(null, null); + void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException; - dependencies.getAccountManager().deleteAccount(); + List getLinkedDevices() throws IOException; - account.setRegistered(false); - } + void removeLinkedDevices(long deviceId) throws IOException; - public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException { - dependencies.getAccountManager().submitRateLimitRecaptchaChallenge(challenge, captcha); - } + void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException; - public List getLinkedDevices() throws IOException { - var devices = dependencies.getAccountManager().getDevices(); - account.setMultiDevice(devices.size() > 1); - 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()); - } + void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException; - public void removeLinkedDevices(int deviceId) throws IOException { - dependencies.getAccountManager().removeDevice(deviceId); - var devices = dependencies.getAccountManager().getDevices(); - account.setMultiDevice(devices.size() > 1); - } + Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws UnregisteredUserException; - public void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException { - var info = DeviceLinkInfo.parseDeviceLinkUri(linkUri); + List getGroups(); - addDevice(info.deviceIdentifier, info.deviceKey); - } - - private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException { - var identityKeyPair = account.getIdentityKeyPair(); - var verificationCode = dependencies.getAccountManager().getNewDeviceVerificationCode(); - - dependencies.getAccountManager() - .addDevice(deviceIdentifier, - deviceKey, - identityKeyPair, - Optional.of(account.getProfileKey().serialize()), - verificationCode); - account.setMultiDevice(true); - } - - public void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException { - if (!account.isMasterDevice()) { - throw new RuntimeException("Only master device can set a PIN"); - } - if (pin.isPresent()) { - final var masterKey = account.getPinMasterKey() != null - ? account.getPinMasterKey() - : KeyUtils.createMasterKey(); - - pinHelper.setRegistrationLockPin(pin.get(), masterKey); - - account.setRegistrationLockPin(pin.get(), masterKey); - } else { - // Remove KBS Pin - pinHelper.removeRegistrationLockPin(); - - account.setRegistrationLockPin(null, null); - } - } - - void refreshPreKeys() throws IOException { - preKeyHelper.refreshPreKeys(); - } - - public Profile getRecipientProfile(RecipientId recipientId) { - return profileHelper.getRecipientProfile(recipientId); - } - - public List getGroups() { - return account.getGroupStore().getGroups(); - } - - public SendGroupMessageResults quitGroup( + SendGroupMessageResults quitGroup( GroupId groupId, Set groupAdmins - ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException { - final var newAdmins = resolveRecipients(groupAdmins); - return groupHelper.quitGroup(groupId, newAdmins); - } + ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException; - public void deleteGroup(GroupId groupId) throws IOException { - groupHelper.deleteGroup(groupId); - } + void deleteGroup(GroupId groupId) throws IOException; - public Pair createGroup( + Pair createGroup( String name, Set members, File avatarFile - ) throws IOException, AttachmentInvalidException { - return groupHelper.createGroup(name, members == null ? null : resolveRecipients(members), avatarFile); - } + ) throws IOException, AttachmentInvalidException; - public SendGroupMessageResults updateGroup( + SendGroupMessageResults updateGroup( GroupId groupId, String name, String description, @@ -511,724 +152,104 @@ public class Manager implements Closeable { File avatarFile, Integer expirationTimer, Boolean isAnnouncementGroup - ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException { - return groupHelper.updateGroup(groupId, - name, - description, - members == null ? null : resolveRecipients(members), - removeMembers == null ? null : resolveRecipients(removeMembers), - admins == null ? null : resolveRecipients(admins), - removeAdmins == null ? null : resolveRecipients(removeAdmins), - resetGroupLink, - groupLinkState, - addMemberPermission, - editDetailsPermission, - avatarFile, - expirationTimer, - isAnnouncementGroup); - } + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException; - public Pair joinGroup( + Pair joinGroup( GroupInviteLinkUrl inviteLinkUrl - ) throws IOException, GroupLinkNotActiveException { - return groupHelper.joinGroup(inviteLinkUrl); - } + ) throws IOException, GroupLinkNotActiveException; - public SendMessageResults sendMessage( - SignalServiceDataMessage.Builder messageBuilder, Set recipients - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - var results = new HashMap>(); - long timestamp = System.currentTimeMillis(); - messageBuilder.withTimestamp(timestamp); - for (final var recipient : recipients) { - if (recipient instanceof RecipientIdentifier.Single) { - final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient); - final var result = sendHelper.sendMessage(messageBuilder, recipientId); - results.put(recipient, List.of(result)); - } else if (recipient instanceof RecipientIdentifier.NoteToSelf) { - final var result = sendHelper.sendSelfMessage(messageBuilder); - results.put(recipient, List.of(result)); - } else if (recipient instanceof RecipientIdentifier.Group) { - final var groupId = ((RecipientIdentifier.Group) recipient).groupId; - final var result = sendHelper.sendAsGroupMessage(messageBuilder, groupId); - results.put(recipient, result); - } - } - return new SendMessageResults(timestamp, results); - } + void sendTypingMessage( + TypingAction action, Set recipients + ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; - public void sendTypingMessage( - SignalServiceTypingMessage.Action action, Set recipients - ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - final var timestamp = System.currentTimeMillis(); - for (var recipient : recipients) { - if (recipient instanceof RecipientIdentifier.Single) { - final var message = new SignalServiceTypingMessage(action, timestamp, Optional.absent()); - final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient); - sendHelper.sendTypingMessage(message, recipientId); - } else if (recipient instanceof RecipientIdentifier.Group) { - final var groupId = ((RecipientIdentifier.Group) recipient).groupId; - final var message = new SignalServiceTypingMessage(action, timestamp, Optional.of(groupId.serialize())); - sendHelper.sendGroupTypingMessage(message, groupId); - } - } - } - - public void sendReadReceipt( + void sendReadReceipt( RecipientIdentifier.Single sender, List messageIds - ) throws IOException, UntrustedIdentityException { - var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, - messageIds, - System.currentTimeMillis()); + ) throws IOException, UntrustedIdentityException; - sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender)); - } - - public void sendViewedReceipt( + void sendViewedReceipt( RecipientIdentifier.Single sender, List messageIds - ) throws IOException, UntrustedIdentityException { - var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED, - messageIds, - System.currentTimeMillis()); + ) throws IOException, UntrustedIdentityException; - sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender)); - } - - public SendMessageResults sendMessage( + SendMessageResults sendMessage( Message message, Set recipients - ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - final var messageBuilder = SignalServiceDataMessage.newBuilder(); - applyMessage(messageBuilder, message); - return sendMessage(messageBuilder, recipients); - } + ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; - private void applyMessage( - final SignalServiceDataMessage.Builder messageBuilder, final Message message - ) throws AttachmentInvalidException, IOException { - messageBuilder.withBody(message.getMessageText()); - final var attachments = message.getAttachments(); - if (attachments != null) { - messageBuilder.withAttachments(attachmentHelper.uploadAttachments(attachments)); - } - } - - public SendMessageResults sendRemoteDeleteMessage( + SendMessageResults sendRemoteDeleteMessage( long targetSentTimestamp, Set recipients - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); - final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); - return sendMessage(messageBuilder, recipients); - } + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; - public SendMessageResults sendMessageReaction( + SendMessageResults sendMessageReaction( String emoji, boolean remove, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set recipients - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - var targetAuthorRecipientId = resolveRecipient(targetAuthor); - var reaction = new SignalServiceDataMessage.Reaction(emoji, - remove, - resolveSignalServiceAddress(targetAuthorRecipientId), - targetSentTimestamp); - final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); - return sendMessage(messageBuilder, recipients); - } + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException; - public SendMessageResults sendEndSessionMessage(Set recipients) throws IOException { - var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage(); + SendMessageResults sendEndSessionMessage(Set recipients) throws IOException; - try { - return sendMessage(messageBuilder, - recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet())); - } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) { - throw new AssertionError(e); - } finally { - for (var recipient : recipients) { - final var recipientId = resolveRecipient(recipient); - account.getSessionStore().deleteAllSessions(recipientId); - } - } - } - - public void setContactName( + void setContactName( RecipientIdentifier.Single recipient, String name - ) throws NotMasterDeviceException, UnregisteredUserException { - if (!account.isMasterDevice()) { - throw new NotMasterDeviceException(); - } - contactHelper.setContactName(resolveRecipient(recipient), name); - } + ) throws NotMasterDeviceException, UnregisteredUserException; - public void setContactBlocked( + void setContactBlocked( RecipientIdentifier.Single recipient, boolean blocked - ) throws NotMasterDeviceException, IOException { - if (!account.isMasterDevice()) { - throw new NotMasterDeviceException(); - } - contactHelper.setContactBlocked(resolveRecipient(recipient), blocked); - // TODO cycle our profile key - syncHelper.sendBlockedList(); - } + ) throws NotMasterDeviceException, IOException; - public void setGroupBlocked( - final GroupId groupId, final boolean blocked - ) throws GroupNotFoundException, IOException { - groupHelper.setGroupBlocked(groupId, blocked); - // TODO cycle our profile key - syncHelper.sendBlockedList(); - } + void setGroupBlocked( + GroupId groupId, boolean blocked + ) throws GroupNotFoundException, IOException; - /** - * Change the expiration timer for a contact - */ - public void setExpirationTimer( + void setExpirationTimer( RecipientIdentifier.Single recipient, int messageExpirationTimer - ) throws IOException { - var recipientId = resolveRecipient(recipient); - contactHelper.setExpirationTimer(recipientId, messageExpirationTimer); - final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); - try { - sendMessage(messageBuilder, Set.of(recipient)); - } catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) { - throw new AssertionError(e); - } - } + ) throws IOException; - /** - * Upload the sticker pack from path. - * - * @param path Path can be a path to a manifest.json file or to a zip file that contains a manifest.json file - * @return if successful, returns the URL to install the sticker pack in the signal app - */ - public URI uploadStickerPack(File path) throws IOException, StickerPackInvalidException { - var manifest = StickerUtils.getSignalServiceStickerManifestUpload(path); + URI uploadStickerPack(File path) throws IOException, StickerPackInvalidException; - var messageSender = dependencies.getMessageSender(); + void requestAllSyncData() throws IOException; - var packKey = KeyUtils.createStickerUploadKey(); - var packIdString = messageSender.uploadStickerManifest(manifest, packKey); - var packId = StickerPackId.deserialize(Hex.fromStringCondensed(packIdString)); - - var sticker = new Sticker(packId, packKey); - account.getStickerStore().updateSticker(sticker); - - try { - return new URI("https", - "signal.art", - "/addstickers/", - "pack_id=" - + URLEncoder.encode(Hex.toStringCondensed(packId.serialize()), StandardCharsets.UTF_8) - + "&pack_key=" - + URLEncoder.encode(Hex.toStringCondensed(packKey), StandardCharsets.UTF_8)); - } catch (URISyntaxException e) { - throw new AssertionError(e); - } - } - - public void requestAllSyncData() throws IOException { - syncHelper.requestAllSyncData(); - retrieveRemoteStorage(); - } - - void retrieveRemoteStorage() throws IOException { - if (account.getStorageKey() != null) { - storageHelper.readDataFromStorage(); - } - } - - private byte[] getSenderCertificate() { - byte[] certificate; - try { - 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; - } - // TODO cache for a day - return certificate; - } - - private RecipientId refreshRegisteredUser(RecipientId recipientId) throws IOException { - final var address = resolveSignalServiceAddress(recipientId); - if (!address.getNumber().isPresent()) { - return recipientId; - } - final var number = address.getNumber().get(); - final var uuid = getRegisteredUser(number); - return resolveRecipientTrusted(new SignalServiceAddress(uuid, number)); - } - - private UUID getRegisteredUser(final String number) throws IOException { - final Map uuidMap; - try { - uuidMap = getRegisteredUsers(Set.of(number)); - } catch (NumberFormatException e) { - throw new UnregisteredUserException(number, e); - } - final var uuid = uuidMap.get(number); - if (uuid == null) { - throw new UnregisteredUserException(number, null); - } - return uuid; - } - - private Map getRegisteredUsers(final Set numbers) throws IOException { - final Map registeredUsers; - try { - registeredUsers = dependencies.getAccountManager() - .getRegisteredUsers(ServiceConfig.getIasKeyStore(), - numbers, - serviceEnvironmentConfig.getCdsMrenclave()); - } catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | SignatureException | UnauthenticatedResponseException | InvalidKeyException e) { - throw new IOException(e); - } - - // Store numbers as recipients so we have the number/uuid association - registeredUsers.forEach((number, uuid) -> resolveRecipientTrusted(new SignalServiceAddress(uuid, number))); - - return registeredUsers; - } - - public void sendTypingMessage( - TypingAction action, Set recipients - ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { - sendTypingMessage(action.toSignalService(), recipients); - } - - private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) { - Set queuedActions = new HashSet<>(); - for (var cachedMessage : account.getMessageCache().getCachedMessages()) { - var actions = retryFailedReceivedMessage(handler, ignoreAttachments, cachedMessage); - if (actions != null) { - queuedActions.addAll(actions); - } - } - handleQueuedActions(queuedActions); - } - - private List retryFailedReceivedMessage( - final ReceiveMessageHandler handler, final boolean ignoreAttachments, final CachedMessage cachedMessage - ) { - var envelope = cachedMessage.loadEnvelope(); - if (envelope == null) { - cachedMessage.delete(); - return null; - } - - final var result = incomingMessageHandler.handleRetryEnvelope(envelope, ignoreAttachments, handler); - final var actions = result.first(); - final var exception = result.second(); - - if (exception instanceof UntrustedIdentityException) { - if (System.currentTimeMillis() - envelope.getServerDeliveredTimestamp() > 1000L * 60 * 60 * 24 * 30) { - // Envelope is more than a month old, cleaning up. - cachedMessage.delete(); - return null; - } - if (!envelope.hasSourceUuid()) { - final var identifier = ((UntrustedIdentityException) exception).getSender(); - final var recipientId = account.getRecipientStore().resolveRecipient(identifier); - try { - account.getMessageCache().replaceSender(cachedMessage, recipientId); - } catch (IOException ioException) { - logger.warn("Failed to move cached message to recipient folder: {}", ioException.getMessage()); - } - } - return null; - } - - // If successful and for all other errors that are not recoverable, delete the cached message - cachedMessage.delete(); - return actions; - } - - public void receiveMessages( + void receiveMessages( long timeout, TimeUnit unit, boolean returnOnTimeout, boolean ignoreAttachments, ReceiveMessageHandler handler - ) throws IOException { - retryFailedReceivedMessages(handler, ignoreAttachments); + ) throws IOException; - Set queuedActions = new HashSet<>(); + boolean hasCaughtUpWithOldMessages(); - final var signalWebSocket = dependencies.getSignalWebSocket(); - signalWebSocket.connect(); + boolean isContactBlocked(RecipientIdentifier.Single recipient); - hasCaughtUpWithOldMessages = false; + File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId); - while (!Thread.interrupted()) { - SignalServiceEnvelope envelope; - final CachedMessage[] cachedMessage = {null}; - account.setLastReceiveTimestamp(System.currentTimeMillis()); - logger.debug("Checking for new message from server"); - try { - var result = signalWebSocket.readOrEmpty(unit.toMillis(timeout), envelope1 -> { - final var recipientId = envelope1.hasSourceUuid() - ? resolveRecipient(envelope1.getSourceAddress()) - : null; - // store message on disk, before acknowledging receipt to the server - cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId); - }); - if (result.isPresent()) { - envelope = result.get(); - logger.debug("New message received from server"); - } else { - logger.debug("Received indicator that server queue is empty"); - handleQueuedActions(queuedActions); - queuedActions.clear(); + void sendContacts() throws IOException; - hasCaughtUpWithOldMessages = true; - synchronized (this) { - this.notifyAll(); - } + List> getContacts(); - // Continue to wait another timeout for new messages - continue; - } - } catch (AssertionError e) { - if (e.getCause() instanceof InterruptedException) { - Thread.currentThread().interrupt(); - break; - } else { - throw e; - } - } catch (WebSocketUnavailableException e) { - logger.debug("Pipe unexpectedly unavailable, connecting"); - signalWebSocket.connect(); - continue; - } catch (TimeoutException e) { - if (returnOnTimeout) return; - continue; - } + String getContactOrProfileName(RecipientIdentifier.Single recipient); - final var result = incomingMessageHandler.handleEnvelope(envelope, ignoreAttachments, handler); - queuedActions.addAll(result.first()); - final var exception = result.second(); + Group getGroup(GroupId groupId); - if (hasCaughtUpWithOldMessages) { - handleQueuedActions(queuedActions); - } - if (cachedMessage[0] != null) { - if (exception instanceof UntrustedIdentityException) { - final var address = ((UntrustedIdentityException) exception).getSender(); - final var recipientId = resolveRecipient(address); - if (!envelope.hasSourceUuid()) { - try { - cachedMessage[0] = account.getMessageCache().replaceSender(cachedMessage[0], recipientId); - } catch (IOException ioException) { - logger.warn("Failed to move cached message to recipient folder: {}", - ioException.getMessage()); - } - } - } else { - cachedMessage[0].delete(); - } - } - } - handleQueuedActions(queuedActions); - } + List getIdentities(); - public boolean hasCaughtUpWithOldMessages() { - return hasCaughtUpWithOldMessages; - } + List getIdentities(RecipientIdentifier.Single recipient); - private void handleQueuedActions(final Collection queuedActions) { - var interrupted = false; - for (var action : queuedActions) { - try { - action.execute(context); - } catch (Throwable e) { - if ((e instanceof AssertionError || e instanceof RuntimeException) - && e.getCause() instanceof InterruptedException) { - interrupted = true; - continue; - } - logger.warn("Message action failed.", e); - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } + boolean trustIdentityVerified(RecipientIdentifier.Single recipient, byte[] fingerprint); - public boolean isContactBlocked(final RecipientIdentifier.Single recipient) { - final RecipientId recipientId; - try { - recipientId = resolveRecipient(recipient); - } catch (UnregisteredUserException e) { - return false; - } - return contactHelper.isContactBlocked(recipientId); - } + boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, String safetyNumber); - public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { - return attachmentHelper.getAttachmentFile(attachmentId); - } + boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, byte[] safetyNumber); - public void sendContacts() throws IOException { - syncHelper.sendContacts(); - } + boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient); - public List> getContacts() { - return account.getContactStore().getContacts(); - } + String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey); - public String getContactOrProfileName(RecipientIdentifier.Single recipientIdentifier) { - final RecipientId recipientId; - try { - recipientId = resolveRecipient(recipientIdentifier); - } catch (UnregisteredUserException e) { - return null; - } - - final var contact = account.getContactStore().getContact(recipientId); - if (contact != null && !Util.isEmpty(contact.getName())) { - return contact.getName(); - } - - final var profile = getRecipientProfile(recipientId); - if (profile != null) { - return profile.getDisplayName(); - } - - return null; - } - - public GroupInfo getGroup(GroupId groupId) { - return groupHelper.getGroup(groupId); - } - - public List getIdentities() { - return account.getIdentityKeyStore().getIdentities(); - } - - public List getIdentities(RecipientIdentifier.Single recipient) { - IdentityInfo identity; - try { - identity = account.getIdentityKeyStore().getIdentity(resolveRecipient(recipient)); - } catch (UnregisteredUserException e) { - identity = null; - } - return identity == null ? List.of() : List.of(identity); - } - - /** - * Trust this the identity with this fingerprint - * - * @param recipient username of the identity - * @param fingerprint Fingerprint - */ - public boolean trustIdentityVerified(RecipientIdentifier.Single recipient, byte[] fingerprint) { - RecipientId recipientId; - try { - recipientId = resolveRecipient(recipient); - } catch (UnregisteredUserException e) { - return false; - } - return trustIdentity(recipientId, - identityKey -> Arrays.equals(identityKey.serialize(), fingerprint), - TrustLevel.TRUSTED_VERIFIED); - } - - /** - * Trust this the identity with this safety number - * - * @param recipient username of the identity - * @param safetyNumber Safety number - */ - public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, String safetyNumber) { - RecipientId recipientId; - try { - recipientId = resolveRecipient(recipient); - } catch (UnregisteredUserException e) { - return false; - } - var address = resolveSignalServiceAddress(recipientId); - return trustIdentity(recipientId, - identityKey -> safetyNumber.equals(computeSafetyNumber(address, identityKey)), - TrustLevel.TRUSTED_VERIFIED); - } - - /** - * Trust this the identity with this scannable safety number - * - * @param recipient username of the identity - * @param safetyNumber Scannable safety number - */ - public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, byte[] safetyNumber) { - RecipientId recipientId; - try { - recipientId = resolveRecipient(recipient); - } catch (UnregisteredUserException e) { - return false; - } - var address = resolveSignalServiceAddress(recipientId); - return trustIdentity(recipientId, identityKey -> { - final var fingerprint = computeSafetyNumberFingerprint(address, identityKey); - try { - return fingerprint != null && fingerprint.getScannableFingerprint().compareTo(safetyNumber); - } catch (FingerprintVersionMismatchException | FingerprintParsingException e) { - return false; - } - }, TrustLevel.TRUSTED_VERIFIED); - } - - /** - * Trust all keys of this identity without verification - * - * @param recipient username of the identity - */ - public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) { - RecipientId recipientId; - try { - recipientId = resolveRecipient(recipient); - } catch (UnregisteredUserException e) { - return false; - } - return trustIdentity(recipientId, identityKey -> true, TrustLevel.TRUSTED_UNVERIFIED); - } - - private boolean trustIdentity( - RecipientId recipientId, Function verifier, TrustLevel trustLevel - ) { - var identity = account.getIdentityKeyStore().getIdentity(recipientId); - if (identity == null) { - return false; - } - - if (!verifier.apply(identity.getIdentityKey())) { - return false; - } - - account.getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), trustLevel); - try { - var address = resolveSignalServiceAddress(recipientId); - syncHelper.sendVerifiedMessage(address, identity.getIdentityKey(), trustLevel); - } catch (IOException e) { - logger.warn("Failed to send verification sync message: {}", e.getMessage()); - } - - return true; - } - - private void handleIdentityFailure( - final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure - ) { - final var identityKey = identityFailure.getIdentityKey(); - if (identityKey != null) { - final var newIdentity = account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date()); - if (newIdentity) { - account.getSessionStore().archiveSessions(recipientId); - } - } else { - // Retrieve profile to get the current identity key from the server - profileHelper.refreshRecipientProfile(recipientId); - } - } - - public String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) { - final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey); - return fingerprint == null ? null : fingerprint.getDisplayableFingerprint().getDisplayText(); - } - - public byte[] computeSafetyNumberForScanning(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) { - final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey); - return fingerprint == null ? null : fingerprint.getScannableFingerprint().getSerialized(); - } - - private Fingerprint computeSafetyNumberFingerprint( - final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey - ) { - return Utils.computeSafetyNumber(capabilities.isUuid(), - account.getSelfAddress(), - account.getIdentityKeyPair().getPublicKey(), - theirAddress, - theirIdentityKey); - } - - public SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address) { - return resolveSignalServiceAddress(resolveRecipient(address)); - } - - public SignalServiceAddress resolveSignalServiceAddress(UUID uuid) { - return resolveSignalServiceAddress(account.getRecipientStore().resolveRecipient(uuid)); - } - - public SignalServiceAddress resolveSignalServiceAddress(RecipientId recipientId) { - final var address = account.getRecipientStore().resolveRecipientAddress(recipientId); - if (address.getUuid().isPresent()) { - return address.toSignalServiceAddress(); - } - - // Address in recipient store doesn't have a uuid, this shouldn't happen - // Try to retrieve the uuid from the server - final var number = address.getNumber().get(); - try { - return resolveSignalServiceAddress(getRegisteredUser(number)); - } catch (IOException e) { - logger.warn("Failed to get uuid for e164 number: {}", number, e); - // Return SignalServiceAddress with unknown UUID - return address.toSignalServiceAddress(); - } - } - - private Set resolveRecipients(Collection recipients) throws UnregisteredUserException { - final var recipientIds = new HashSet(recipients.size()); - for (var number : recipients) { - final var recipientId = resolveRecipient(number); - recipientIds.add(recipientId); - } - return recipientIds; - } - - private RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredUserException { - if (recipient instanceof RecipientIdentifier.Uuid) { - return account.getRecipientStore().resolveRecipient(((RecipientIdentifier.Uuid) recipient).uuid); - } else { - final var number = ((RecipientIdentifier.Number) recipient).number; - return account.getRecipientStore().resolveRecipient(number, () -> { - try { - return getRegisteredUser(number); - } catch (IOException e) { - return null; - } - }); - } - } - - private RecipientId resolveRecipient(SignalServiceAddress address) { - return account.getRecipientStore().resolveRecipient(address); - } - - private RecipientId resolveRecipientTrusted(SignalServiceAddress address) { - return account.getRecipientStore().resolveRecipientTrusted(address); - } + SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address); @Override - public void close() throws IOException { - close(true); - } + void close() throws IOException; - private void close(boolean closeAccount) throws IOException { - executor.shutdown(); - - dependencies.getSignalWebSocket().disconnect(); - - if (closeAccount && account != null) { - account.close(); - } - account = null; - } - - public interface ReceiveMessageHandler { + interface ReceiveMessageHandler { void handleMessage(SignalServiceEnvelope envelope, SignalServiceContent decryptedContent, Throwable e); } diff --git a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java new file mode 100644 index 00000000..0fd1eb33 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java @@ -0,0 +1,1320 @@ +/* + Copyright (C) 2015-2021 AsamK and contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ +package org.asamk.signal.manager; + +import org.asamk.signal.manager.actions.HandleAction; +import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.Message; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.config.ServiceConfig; +import org.asamk.signal.manager.config.ServiceEnvironmentConfig; +import org.asamk.signal.manager.groups.GroupId; +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.GroupSendingNotAllowedException; +import org.asamk.signal.manager.groups.LastGroupAdminException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.helper.AttachmentHelper; +import org.asamk.signal.manager.helper.ContactHelper; +import org.asamk.signal.manager.helper.GroupHelper; +import org.asamk.signal.manager.helper.GroupV2Helper; +import org.asamk.signal.manager.helper.IncomingMessageHandler; +import org.asamk.signal.manager.helper.PinHelper; +import org.asamk.signal.manager.helper.PreKeyHelper; +import org.asamk.signal.manager.helper.ProfileHelper; +import org.asamk.signal.manager.helper.SendHelper; +import org.asamk.signal.manager.helper.StorageHelper; +import org.asamk.signal.manager.helper.SyncHelper; +import org.asamk.signal.manager.helper.UnidentifiedAccessHelper; +import org.asamk.signal.manager.jobs.Context; +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.messageCache.CachedMessage; +import org.asamk.signal.manager.storage.recipients.Contact; +import org.asamk.signal.manager.storage.recipients.Profile; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.stickers.Sticker; +import org.asamk.signal.manager.storage.stickers.StickerPackId; +import org.asamk.signal.manager.util.KeyUtils; +import org.asamk.signal.manager.util.StickerUtils; +import org.asamk.signal.manager.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.fingerprint.Fingerprint; +import org.whispersystems.libsignal.fingerprint.FingerprintParsingException; +import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalSessionLock; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.util.DeviceNameUtil; +import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; +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; +import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SignatureException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +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; + +import static org.asamk.signal.manager.config.ServiceConfig.capabilities; + +public class ManagerImpl implements Manager { + + private final static Logger logger = LoggerFactory.getLogger(ManagerImpl.class); + + private final ServiceEnvironmentConfig serviceEnvironmentConfig; + private final SignalDependencies dependencies; + + private SignalAccount account; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + private final ProfileHelper profileHelper; + private final PinHelper pinHelper; + private final StorageHelper storageHelper; + private final SendHelper sendHelper; + private final SyncHelper syncHelper; + private final AttachmentHelper attachmentHelper; + private final GroupHelper groupHelper; + private final ContactHelper contactHelper; + private final IncomingMessageHandler incomingMessageHandler; + private final PreKeyHelper preKeyHelper; + + private final Context context; + private boolean hasCaughtUpWithOldMessages = false; + + ManagerImpl( + SignalAccount account, + PathConfig pathConfig, + ServiceEnvironmentConfig serviceEnvironmentConfig, + String userAgent + ) { + this.account = account; + this.serviceEnvironmentConfig = serviceEnvironmentConfig; + + final var credentialsProvider = new DynamicCredentialsProvider(account.getUuid(), + account.getUsername(), + account.getPassword(), + account.getDeviceId()); + final var sessionLock = new SignalSessionLock() { + private final ReentrantLock LEGACY_LOCK = new ReentrantLock(); + + @Override + public Lock acquire() { + LEGACY_LOCK.lock(); + return LEGACY_LOCK::unlock; + } + }; + this.dependencies = new SignalDependencies(serviceEnvironmentConfig, + userAgent, + credentialsProvider, + account.getSignalProtocolStore(), + executor, + sessionLock); + final var avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); + final var attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); + final var stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath()); + + this.attachmentHelper = new AttachmentHelper(dependencies, attachmentStore); + this.pinHelper = new PinHelper(dependencies.getKeyBackupService()); + final var unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey, + account.getProfileStore()::getProfileKey, + this::getRecipientProfile, + this::getSenderCertificate); + this.profileHelper = new ProfileHelper(account, + dependencies, + avatarStore, + account.getProfileStore()::getProfileKey, + unidentifiedAccessHelper::getAccessFor, + this::resolveSignalServiceAddress); + final GroupV2Helper groupV2Helper = new GroupV2Helper(profileHelper::getRecipientProfileKeyCredential, + this::getRecipientProfile, + account::getSelfRecipientId, + dependencies.getGroupsV2Operations(), + dependencies.getGroupsV2Api(), + this::resolveSignalServiceAddress); + this.sendHelper = new SendHelper(account, + dependencies, + unidentifiedAccessHelper, + this::resolveSignalServiceAddress, + account.getRecipientStore(), + this::handleIdentityFailure, + this::getGroupInfo, + this::refreshRegisteredUser); + this.groupHelper = new GroupHelper(account, + dependencies, + attachmentHelper, + sendHelper, + groupV2Helper, + avatarStore, + this::resolveSignalServiceAddress, + account.getRecipientStore()); + this.storageHelper = new StorageHelper(account, dependencies, groupHelper, profileHelper); + this.contactHelper = new ContactHelper(account); + this.syncHelper = new SyncHelper(account, + attachmentHelper, + sendHelper, + groupHelper, + avatarStore, + this::resolveSignalServiceAddress); + preKeyHelper = new PreKeyHelper(account, dependencies); + + this.context = new Context(account, + dependencies, + stickerPackStore, + sendHelper, + groupHelper, + syncHelper, + profileHelper, + storageHelper, + preKeyHelper); + var jobExecutor = new JobExecutor(context); + + this.incomingMessageHandler = new IncomingMessageHandler(account, + dependencies, + account.getRecipientStore(), + this::resolveSignalServiceAddress, + groupHelper, + contactHelper, + attachmentHelper, + syncHelper, + this::getRecipientProfile, + jobExecutor); + } + + @Override + public String getSelfNumber() { + return account.getUsername(); + } + + @Override + public void checkAccountState() throws IOException { + if (account.getLastReceiveTimestamp() == 0) { + logger.info("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); + } + } + preKeyHelper.refreshPreKeysIfNecessary(); + if (account.getUuid() == null) { + account.setUuid(dependencies.getAccountManager().getOwnUuid()); + } + updateAccountAttributes(null); + } + + /** + * This is used for checking a set of phone numbers for registration on Signal + * + * @param numbers The set of phone number in question + * @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null. + * @throws IOException if its unable to get the contacts to check if they're registered + */ + @Override + public Map> areUsersRegistered(Set numbers) throws IOException { + Map canonicalizedNumbers = numbers.stream().collect(Collectors.toMap(n -> n, n -> { + try { + return PhoneNumberFormatter.formatNumber(n, account.getUsername()); + } catch (InvalidNumberException e) { + return ""; + } + })); + + // Note "registeredUsers" has no optionals. It only gives us info on users who are registered + var registeredUsers = getRegisteredUsers(canonicalizedNumbers.values() + .stream() + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet())); + + return numbers.stream().collect(Collectors.toMap(n -> n, n -> { + final var number = canonicalizedNumbers.get(n); + final var uuid = registeredUsers.get(number); + return new Pair<>(number.isEmpty() ? null : number, uuid); + })); + } + + @Override + public void updateAccountAttributes(String deviceName) throws IOException { + final String encryptedDeviceName; + if (deviceName == null) { + encryptedDeviceName = account.getEncryptedDeviceName(); + } else { + final var privateKey = account.getIdentityKeyPair().getPrivateKey(); + encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); + account.setEncryptedDeviceName(encryptedDeviceName); + } + dependencies.getAccountManager() + .setAccountAttributes(encryptedDeviceName, + null, + account.getLocalRegistrationId(), + true, + null, + account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(), + account.getSelfUnidentifiedAccessKey(), + account.isUnrestrictedUnidentifiedAccess(), + capabilities, + account.isDiscoverableByPhoneNumber()); + } + + @Override + public void updateConfiguration( + final Boolean readReceipts, + final Boolean unidentifiedDeliveryIndicators, + final Boolean typingIndicators, + final Boolean linkPreviews + ) throws IOException, NotMasterDeviceException { + if (!account.isMasterDevice()) { + throw new NotMasterDeviceException(); + } + + final var configurationStore = account.getConfigurationStore(); + if (readReceipts != null) { + configurationStore.setReadReceipts(readReceipts); + } + if (unidentifiedDeliveryIndicators != null) { + configurationStore.setUnidentifiedDeliveryIndicators(unidentifiedDeliveryIndicators); + } + if (typingIndicators != null) { + configurationStore.setTypingIndicators(typingIndicators); + } + if (linkPreviews != null) { + configurationStore.setLinkPreviews(linkPreviews); + } + syncHelper.sendConfigurationMessage(); + } + + /** + * @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), + */ + @Override + public void setProfile( + String givenName, final String familyName, String about, String aboutEmoji, Optional avatar + ) throws IOException { + profileHelper.setProfile(givenName, familyName, about, aboutEmoji, avatar); + syncHelper.sendSyncFetchProfileMessage(); + } + + @Override + public void unregister() throws IOException { + // 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. + dependencies.getAccountManager().setGcmId(Optional.absent()); + + account.setRegistered(false); + } + + @Override + public void deleteAccount() throws IOException { + try { + pinHelper.removeRegistrationLockPin(); + } catch (UnauthenticatedResponseException e) { + logger.warn("Failed to remove registration lock pin"); + } + account.setRegistrationLockPin(null, null); + + dependencies.getAccountManager().deleteAccount(); + + account.setRegistered(false); + } + + @Override + public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException { + dependencies.getAccountManager().submitRateLimitRecaptchaChallenge(challenge, captcha); + } + + @Override + public List getLinkedDevices() throws IOException { + var devices = dependencies.getAccountManager().getDevices(); + account.setMultiDevice(devices.size() > 1); + 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(), + d.getId() == account.getDeviceId()); + }).collect(Collectors.toList()); + } + + @Override + public void removeLinkedDevices(long deviceId) throws IOException { + dependencies.getAccountManager().removeDevice(deviceId); + var devices = dependencies.getAccountManager().getDevices(); + account.setMultiDevice(devices.size() > 1); + } + + @Override + public void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException { + var info = DeviceLinkInfo.parseDeviceLinkUri(linkUri); + + addDevice(info.deviceIdentifier, info.deviceKey); + } + + private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException { + var identityKeyPair = account.getIdentityKeyPair(); + var verificationCode = dependencies.getAccountManager().getNewDeviceVerificationCode(); + + dependencies.getAccountManager() + .addDevice(deviceIdentifier, + deviceKey, + identityKeyPair, + Optional.of(account.getProfileKey().serialize()), + verificationCode); + account.setMultiDevice(true); + } + + @Override + public void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException { + if (!account.isMasterDevice()) { + throw new RuntimeException("Only master device can set a PIN"); + } + if (pin.isPresent()) { + final var masterKey = account.getPinMasterKey() != null + ? account.getPinMasterKey() + : KeyUtils.createMasterKey(); + + pinHelper.setRegistrationLockPin(pin.get(), masterKey); + + account.setRegistrationLockPin(pin.get(), masterKey); + } else { + // Remove KBS Pin + pinHelper.removeRegistrationLockPin(); + + account.setRegistrationLockPin(null, null); + } + } + + void refreshPreKeys() throws IOException { + preKeyHelper.refreshPreKeys(); + } + + @Override + public Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws UnregisteredUserException { + return profileHelper.getRecipientProfile(resolveRecipient(recipient)); + } + + private Profile getRecipientProfile(RecipientId recipientId) { + return profileHelper.getRecipientProfile(recipientId); + } + + @Override + public List getGroups() { + return account.getGroupStore().getGroups().stream().map(this::toGroup).collect(Collectors.toList()); + } + + private Group toGroup(final GroupInfo groupInfo) { + if (groupInfo == null) { + return null; + } + + return new Group(groupInfo.getGroupId(), + groupInfo.getTitle(), + groupInfo.getDescription(), + groupInfo.getGroupInviteLink(), + groupInfo.getMembers() + .stream() + .map(account.getRecipientStore()::resolveRecipientAddress) + .collect(Collectors.toSet()), + groupInfo.getPendingMembers() + .stream() + .map(account.getRecipientStore()::resolveRecipientAddress) + .collect(Collectors.toSet()), + groupInfo.getRequestingMembers() + .stream() + .map(account.getRecipientStore()::resolveRecipientAddress) + .collect(Collectors.toSet()), + groupInfo.getAdminMembers() + .stream() + .map(account.getRecipientStore()::resolveRecipientAddress) + .collect(Collectors.toSet()), + groupInfo.isBlocked(), + groupInfo.getMessageExpirationTime(), + groupInfo.isAnnouncementGroup(), + groupInfo.isMember(account.getSelfRecipientId())); + } + + @Override + public SendGroupMessageResults quitGroup( + GroupId groupId, Set groupAdmins + ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException { + final var newAdmins = resolveRecipients(groupAdmins); + return groupHelper.quitGroup(groupId, newAdmins); + } + + @Override + public void deleteGroup(GroupId groupId) throws IOException { + groupHelper.deleteGroup(groupId); + } + + @Override + public Pair createGroup( + String name, Set members, File avatarFile + ) throws IOException, AttachmentInvalidException { + return groupHelper.createGroup(name, members == null ? null : resolveRecipients(members), avatarFile); + } + + @Override + public SendGroupMessageResults updateGroup( + GroupId groupId, + String name, + String description, + Set members, + Set removeMembers, + Set admins, + Set removeAdmins, + boolean resetGroupLink, + GroupLinkState groupLinkState, + GroupPermission addMemberPermission, + GroupPermission editDetailsPermission, + File avatarFile, + Integer expirationTimer, + Boolean isAnnouncementGroup + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException { + return groupHelper.updateGroup(groupId, + name, + description, + members == null ? null : resolveRecipients(members), + removeMembers == null ? null : resolveRecipients(removeMembers), + admins == null ? null : resolveRecipients(admins), + removeAdmins == null ? null : resolveRecipients(removeAdmins), + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer, + isAnnouncementGroup); + } + + @Override + public Pair joinGroup( + GroupInviteLinkUrl inviteLinkUrl + ) throws IOException, GroupLinkNotActiveException { + return groupHelper.joinGroup(inviteLinkUrl); + } + + private SendMessageResults sendMessage( + SignalServiceDataMessage.Builder messageBuilder, Set recipients + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + var results = new HashMap>(); + long timestamp = System.currentTimeMillis(); + messageBuilder.withTimestamp(timestamp); + for (final var recipient : recipients) { + if (recipient instanceof RecipientIdentifier.Single) { + final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient); + final var result = sendHelper.sendMessage(messageBuilder, recipientId); + results.put(recipient, List.of(result)); + } else if (recipient instanceof RecipientIdentifier.NoteToSelf) { + final var result = sendHelper.sendSelfMessage(messageBuilder); + results.put(recipient, List.of(result)); + } else if (recipient instanceof RecipientIdentifier.Group) { + final var groupId = ((RecipientIdentifier.Group) recipient).groupId; + final var result = sendHelper.sendAsGroupMessage(messageBuilder, groupId); + results.put(recipient, result); + } + } + return new SendMessageResults(timestamp, results); + } + + private void sendTypingMessage( + SignalServiceTypingMessage.Action action, Set recipients + ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + final var timestamp = System.currentTimeMillis(); + for (var recipient : recipients) { + if (recipient instanceof RecipientIdentifier.Single) { + final var message = new SignalServiceTypingMessage(action, timestamp, Optional.absent()); + final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient); + sendHelper.sendTypingMessage(message, recipientId); + } else if (recipient instanceof RecipientIdentifier.Group) { + final var groupId = ((RecipientIdentifier.Group) recipient).groupId; + final var message = new SignalServiceTypingMessage(action, timestamp, Optional.of(groupId.serialize())); + sendHelper.sendGroupTypingMessage(message, groupId); + } + } + } + + @Override + public void sendTypingMessage( + TypingAction action, Set recipients + ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + sendTypingMessage(action.toSignalService(), recipients); + } + + @Override + public void sendReadReceipt( + RecipientIdentifier.Single sender, List messageIds + ) throws IOException, UntrustedIdentityException { + var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, + messageIds, + System.currentTimeMillis()); + + sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender)); + } + + @Override + public void sendViewedReceipt( + RecipientIdentifier.Single sender, List messageIds + ) throws IOException, UntrustedIdentityException { + var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED, + messageIds, + System.currentTimeMillis()); + + sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender)); + } + + @Override + public SendMessageResults sendMessage( + Message message, Set recipients + ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + final var messageBuilder = SignalServiceDataMessage.newBuilder(); + applyMessage(messageBuilder, message); + return sendMessage(messageBuilder, recipients); + } + + private void applyMessage( + final SignalServiceDataMessage.Builder messageBuilder, final Message message + ) throws AttachmentInvalidException, IOException { + messageBuilder.withBody(message.getMessageText()); + final var attachments = message.getAttachments(); + if (attachments != null) { + messageBuilder.withAttachments(attachmentHelper.uploadAttachments(attachments)); + } + } + + @Override + public SendMessageResults sendRemoteDeleteMessage( + long targetSentTimestamp, Set recipients + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); + return sendMessage(messageBuilder, recipients); + } + + @Override + public SendMessageResults sendMessageReaction( + String emoji, + boolean remove, + RecipientIdentifier.Single targetAuthor, + long targetSentTimestamp, + Set recipients + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + var targetAuthorRecipientId = resolveRecipient(targetAuthor); + var reaction = new SignalServiceDataMessage.Reaction(emoji, + remove, + resolveSignalServiceAddress(targetAuthorRecipientId), + targetSentTimestamp); + final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction); + return sendMessage(messageBuilder, recipients); + } + + @Override + public SendMessageResults sendEndSessionMessage(Set recipients) throws IOException { + var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage(); + + try { + return sendMessage(messageBuilder, + recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet())); + } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) { + throw new AssertionError(e); + } finally { + for (var recipient : recipients) { + final var recipientId = resolveRecipient(recipient); + account.getSessionStore().deleteAllSessions(recipientId); + } + } + } + + @Override + public void setContactName( + RecipientIdentifier.Single recipient, String name + ) throws NotMasterDeviceException, UnregisteredUserException { + if (!account.isMasterDevice()) { + throw new NotMasterDeviceException(); + } + contactHelper.setContactName(resolveRecipient(recipient), name); + } + + @Override + public void setContactBlocked( + RecipientIdentifier.Single recipient, boolean blocked + ) throws NotMasterDeviceException, IOException { + if (!account.isMasterDevice()) { + throw new NotMasterDeviceException(); + } + contactHelper.setContactBlocked(resolveRecipient(recipient), blocked); + // TODO cycle our profile key + syncHelper.sendBlockedList(); + } + + @Override + public void setGroupBlocked( + final GroupId groupId, final boolean blocked + ) throws GroupNotFoundException, IOException { + groupHelper.setGroupBlocked(groupId, blocked); + // TODO cycle our profile key + syncHelper.sendBlockedList(); + } + + /** + * Change the expiration timer for a contact + */ + @Override + public void setExpirationTimer( + RecipientIdentifier.Single recipient, int messageExpirationTimer + ) throws IOException { + var recipientId = resolveRecipient(recipient); + contactHelper.setExpirationTimer(recipientId, messageExpirationTimer); + final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); + try { + sendMessage(messageBuilder, Set.of(recipient)); + } catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) { + throw new AssertionError(e); + } + } + + /** + * Upload the sticker pack from path. + * + * @param path Path can be a path to a manifest.json file or to a zip file that contains a manifest.json file + * @return if successful, returns the URL to install the sticker pack in the signal app + */ + @Override + public URI uploadStickerPack(File path) throws IOException, StickerPackInvalidException { + var manifest = StickerUtils.getSignalServiceStickerManifestUpload(path); + + var messageSender = dependencies.getMessageSender(); + + var packKey = KeyUtils.createStickerUploadKey(); + var packIdString = messageSender.uploadStickerManifest(manifest, packKey); + var packId = StickerPackId.deserialize(Hex.fromStringCondensed(packIdString)); + + var sticker = new Sticker(packId, packKey); + account.getStickerStore().updateSticker(sticker); + + try { + return new URI("https", + "signal.art", + "/addstickers/", + "pack_id=" + + URLEncoder.encode(Hex.toStringCondensed(packId.serialize()), StandardCharsets.UTF_8) + + "&pack_key=" + + URLEncoder.encode(Hex.toStringCondensed(packKey), StandardCharsets.UTF_8)); + } catch (URISyntaxException e) { + throw new AssertionError(e); + } + } + + @Override + public void requestAllSyncData() throws IOException { + syncHelper.requestAllSyncData(); + retrieveRemoteStorage(); + } + + void retrieveRemoteStorage() throws IOException { + if (account.getStorageKey() != null) { + storageHelper.readDataFromStorage(); + } + } + + private byte[] getSenderCertificate() { + byte[] certificate; + try { + 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; + } + // TODO cache for a day + return certificate; + } + + private RecipientId refreshRegisteredUser(RecipientId recipientId) throws IOException { + final var address = resolveSignalServiceAddress(recipientId); + if (!address.getNumber().isPresent()) { + return recipientId; + } + final var number = address.getNumber().get(); + final var uuid = getRegisteredUser(number); + return resolveRecipientTrusted(new SignalServiceAddress(uuid, number)); + } + + private UUID getRegisteredUser(final String number) throws IOException { + final Map uuidMap; + try { + uuidMap = getRegisteredUsers(Set.of(number)); + } catch (NumberFormatException e) { + throw new UnregisteredUserException(number, e); + } + final var uuid = uuidMap.get(number); + if (uuid == null) { + throw new UnregisteredUserException(number, null); + } + return uuid; + } + + private Map getRegisteredUsers(final Set numbers) throws IOException { + final Map registeredUsers; + try { + registeredUsers = dependencies.getAccountManager() + .getRegisteredUsers(ServiceConfig.getIasKeyStore(), + numbers, + serviceEnvironmentConfig.getCdsMrenclave()); + } catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | SignatureException | UnauthenticatedResponseException | InvalidKeyException e) { + throw new IOException(e); + } + + // Store numbers as recipients so we have the number/uuid association + registeredUsers.forEach((number, uuid) -> resolveRecipientTrusted(new SignalServiceAddress(uuid, number))); + + return registeredUsers; + } + + private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) { + Set queuedActions = new HashSet<>(); + for (var cachedMessage : account.getMessageCache().getCachedMessages()) { + var actions = retryFailedReceivedMessage(handler, ignoreAttachments, cachedMessage); + if (actions != null) { + queuedActions.addAll(actions); + } + } + handleQueuedActions(queuedActions); + } + + private List retryFailedReceivedMessage( + final ReceiveMessageHandler handler, final boolean ignoreAttachments, final CachedMessage cachedMessage + ) { + var envelope = cachedMessage.loadEnvelope(); + if (envelope == null) { + cachedMessage.delete(); + return null; + } + + final var result = incomingMessageHandler.handleRetryEnvelope(envelope, ignoreAttachments, handler); + final var actions = result.first(); + final var exception = result.second(); + + if (exception instanceof UntrustedIdentityException) { + if (System.currentTimeMillis() - envelope.getServerDeliveredTimestamp() > 1000L * 60 * 60 * 24 * 30) { + // Envelope is more than a month old, cleaning up. + cachedMessage.delete(); + return null; + } + if (!envelope.hasSourceUuid()) { + final var identifier = ((UntrustedIdentityException) exception).getSender(); + final var recipientId = account.getRecipientStore().resolveRecipient(identifier); + try { + account.getMessageCache().replaceSender(cachedMessage, recipientId); + } catch (IOException ioException) { + logger.warn("Failed to move cached message to recipient folder: {}", ioException.getMessage()); + } + } + return null; + } + + // If successful and for all other errors that are not recoverable, delete the cached message + cachedMessage.delete(); + return actions; + } + + @Override + public void receiveMessages( + long timeout, + TimeUnit unit, + boolean returnOnTimeout, + boolean ignoreAttachments, + ReceiveMessageHandler handler + ) throws IOException { + retryFailedReceivedMessages(handler, ignoreAttachments); + + Set queuedActions = new HashSet<>(); + + final var signalWebSocket = dependencies.getSignalWebSocket(); + signalWebSocket.connect(); + + hasCaughtUpWithOldMessages = false; + + while (!Thread.interrupted()) { + SignalServiceEnvelope envelope; + final CachedMessage[] cachedMessage = {null}; + account.setLastReceiveTimestamp(System.currentTimeMillis()); + logger.debug("Checking for new message from server"); + try { + var result = signalWebSocket.readOrEmpty(unit.toMillis(timeout), envelope1 -> { + final var recipientId = envelope1.hasSourceUuid() + ? resolveRecipient(envelope1.getSourceAddress()) + : null; + // store message on disk, before acknowledging receipt to the server + cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId); + }); + if (result.isPresent()) { + envelope = result.get(); + logger.debug("New message received from server"); + } else { + logger.debug("Received indicator that server queue is empty"); + handleQueuedActions(queuedActions); + queuedActions.clear(); + + hasCaughtUpWithOldMessages = true; + synchronized (this) { + this.notifyAll(); + } + + // Continue to wait another timeout for new messages + continue; + } + } catch (AssertionError e) { + if (e.getCause() instanceof InterruptedException) { + Thread.currentThread().interrupt(); + break; + } else { + throw e; + } + } catch (WebSocketUnavailableException e) { + logger.debug("Pipe unexpectedly unavailable, connecting"); + signalWebSocket.connect(); + continue; + } catch (TimeoutException e) { + if (returnOnTimeout) return; + continue; + } + + final var result = incomingMessageHandler.handleEnvelope(envelope, ignoreAttachments, handler); + queuedActions.addAll(result.first()); + final var exception = result.second(); + + if (hasCaughtUpWithOldMessages) { + handleQueuedActions(queuedActions); + } + if (cachedMessage[0] != null) { + if (exception instanceof UntrustedIdentityException) { + final var address = ((UntrustedIdentityException) exception).getSender(); + final var recipientId = resolveRecipient(address); + if (!envelope.hasSourceUuid()) { + try { + cachedMessage[0] = account.getMessageCache().replaceSender(cachedMessage[0], recipientId); + } catch (IOException ioException) { + logger.warn("Failed to move cached message to recipient folder: {}", + ioException.getMessage()); + } + } + } else { + cachedMessage[0].delete(); + } + } + } + handleQueuedActions(queuedActions); + } + + @Override + public boolean hasCaughtUpWithOldMessages() { + return hasCaughtUpWithOldMessages; + } + + private void handleQueuedActions(final Collection queuedActions) { + var interrupted = false; + for (var action : queuedActions) { + try { + action.execute(context); + } catch (Throwable e) { + if ((e instanceof AssertionError || e instanceof RuntimeException) + && e.getCause() instanceof InterruptedException) { + interrupted = true; + continue; + } + logger.warn("Message action failed.", e); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + @Override + public boolean isContactBlocked(final RecipientIdentifier.Single recipient) { + final RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return false; + } + return contactHelper.isContactBlocked(recipientId); + } + + @Override + public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { + return attachmentHelper.getAttachmentFile(attachmentId); + } + + @Override + public void sendContacts() throws IOException { + syncHelper.sendContacts(); + } + + @Override + public List> getContacts() { + return account.getContactStore() + .getContacts() + .stream() + .map(p -> new Pair<>(account.getRecipientStore().resolveRecipientAddress(p.first()), p.second())) + .collect(Collectors.toList()); + } + + @Override + public String getContactOrProfileName(RecipientIdentifier.Single recipient) { + final RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return null; + } + + final var contact = account.getContactStore().getContact(recipientId); + if (contact != null && !Util.isEmpty(contact.getName())) { + return contact.getName(); + } + + final var profile = getRecipientProfile(recipientId); + if (profile != null) { + return profile.getDisplayName(); + } + + return null; + } + + @Override + public Group getGroup(GroupId groupId) { + return toGroup(groupHelper.getGroup(groupId)); + } + + public GroupInfo getGroupInfo(GroupId groupId) { + return groupHelper.getGroup(groupId); + } + + @Override + public List getIdentities() { + return account.getIdentityKeyStore() + .getIdentities() + .stream() + .map(this::toIdentity) + .collect(Collectors.toList()); + } + + private Identity toIdentity(final IdentityInfo identityInfo) { + if (identityInfo == null) { + return null; + } + + final var address = account.getRecipientStore().resolveRecipientAddress(identityInfo.getRecipientId()); + return new Identity(address, + identityInfo.getIdentityKey(), + computeSafetyNumber(address.toSignalServiceAddress(), identityInfo.getIdentityKey()), + computeSafetyNumberForScanning(address.toSignalServiceAddress(), identityInfo.getIdentityKey()), + identityInfo.getTrustLevel(), + identityInfo.getDateAdded()); + } + + @Override + public List getIdentities(RecipientIdentifier.Single recipient) { + IdentityInfo identity; + try { + identity = account.getIdentityKeyStore().getIdentity(resolveRecipient(recipient)); + } catch (UnregisteredUserException e) { + identity = null; + } + return identity == null ? List.of() : List.of(toIdentity(identity)); + } + + /** + * Trust this the identity with this fingerprint + * + * @param recipient username of the identity + * @param fingerprint Fingerprint + */ + @Override + public boolean trustIdentityVerified(RecipientIdentifier.Single recipient, byte[] fingerprint) { + RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return false; + } + return trustIdentity(recipientId, + identityKey -> Arrays.equals(identityKey.serialize(), fingerprint), + TrustLevel.TRUSTED_VERIFIED); + } + + /** + * Trust this the identity with this safety number + * + * @param recipient username of the identity + * @param safetyNumber Safety number + */ + @Override + public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, String safetyNumber) { + RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return false; + } + var address = resolveSignalServiceAddress(recipientId); + return trustIdentity(recipientId, + identityKey -> safetyNumber.equals(computeSafetyNumber(address, identityKey)), + TrustLevel.TRUSTED_VERIFIED); + } + + /** + * Trust this the identity with this scannable safety number + * + * @param recipient username of the identity + * @param safetyNumber Scannable safety number + */ + @Override + public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, byte[] safetyNumber) { + RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return false; + } + var address = resolveSignalServiceAddress(recipientId); + return trustIdentity(recipientId, identityKey -> { + final var fingerprint = computeSafetyNumberFingerprint(address, identityKey); + try { + return fingerprint != null && fingerprint.getScannableFingerprint().compareTo(safetyNumber); + } catch (FingerprintVersionMismatchException | FingerprintParsingException e) { + return false; + } + }, TrustLevel.TRUSTED_VERIFIED); + } + + /** + * Trust all keys of this identity without verification + * + * @param recipient username of the identity + */ + @Override + public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) { + RecipientId recipientId; + try { + recipientId = resolveRecipient(recipient); + } catch (UnregisteredUserException e) { + return false; + } + return trustIdentity(recipientId, identityKey -> true, TrustLevel.TRUSTED_UNVERIFIED); + } + + private boolean trustIdentity( + RecipientId recipientId, Function verifier, TrustLevel trustLevel + ) { + var identity = account.getIdentityKeyStore().getIdentity(recipientId); + if (identity == null) { + return false; + } + + if (!verifier.apply(identity.getIdentityKey())) { + return false; + } + + account.getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), trustLevel); + try { + var address = resolveSignalServiceAddress(recipientId); + syncHelper.sendVerifiedMessage(address, identity.getIdentityKey(), trustLevel); + } catch (IOException e) { + logger.warn("Failed to send verification sync message: {}", e.getMessage()); + } + + return true; + } + + private void handleIdentityFailure( + final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure + ) { + final var identityKey = identityFailure.getIdentityKey(); + if (identityKey != null) { + final var newIdentity = account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date()); + if (newIdentity) { + account.getSessionStore().archiveSessions(recipientId); + } + } else { + // Retrieve profile to get the current identity key from the server + profileHelper.refreshRecipientProfile(recipientId); + } + } + + @Override + public String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) { + final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey); + return fingerprint == null ? null : fingerprint.getDisplayableFingerprint().getDisplayText(); + } + + private byte[] computeSafetyNumberForScanning(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) { + final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey); + return fingerprint == null ? null : fingerprint.getScannableFingerprint().getSerialized(); + } + + private Fingerprint computeSafetyNumberFingerprint( + final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey + ) { + return Utils.computeSafetyNumber(capabilities.isUuid(), + account.getSelfAddress(), + account.getIdentityKeyPair().getPublicKey(), + theirAddress, + theirIdentityKey); + } + + @Override + public SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address) { + return resolveSignalServiceAddress(resolveRecipient(address)); + } + + private SignalServiceAddress resolveSignalServiceAddress(RecipientId recipientId) { + final var address = account.getRecipientStore().resolveRecipientAddress(recipientId); + if (address.getUuid().isPresent()) { + return address.toSignalServiceAddress(); + } + + // Address in recipient store doesn't have a uuid, this shouldn't happen + // Try to retrieve the uuid from the server + final var number = address.getNumber().get(); + final UUID uuid; + try { + uuid = getRegisteredUser(number); + } catch (IOException e) { + logger.warn("Failed to get uuid for e164 number: {}", number, e); + // Return SignalServiceAddress with unknown UUID + return address.toSignalServiceAddress(); + } + return resolveSignalServiceAddress(account.getRecipientStore().resolveRecipient(uuid)); + } + + private Set resolveRecipients(Collection recipients) throws UnregisteredUserException { + final var recipientIds = new HashSet(recipients.size()); + for (var number : recipients) { + final var recipientId = resolveRecipient(number); + recipientIds.add(recipientId); + } + return recipientIds; + } + + private RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredUserException { + if (recipient instanceof RecipientIdentifier.Uuid) { + return account.getRecipientStore().resolveRecipient(((RecipientIdentifier.Uuid) recipient).uuid); + } else { + final var number = ((RecipientIdentifier.Number) recipient).number; + return account.getRecipientStore().resolveRecipient(number, () -> { + try { + return getRegisteredUser(number); + } catch (IOException e) { + return null; + } + }); + } + } + + private RecipientId resolveRecipient(SignalServiceAddress address) { + return account.getRecipientStore().resolveRecipient(address); + } + + private RecipientId resolveRecipientTrusted(SignalServiceAddress address) { + return account.getRecipientStore().resolveRecipientTrusted(address); + } + + @Override + public void close() throws IOException { + close(true); + } + + private void close(boolean closeAccount) throws IOException { + executor.shutdown(); + + dependencies.getSignalWebSocket().disconnect(); + + if (closeAccount && account != null) { + account.close(); + } + account = null; + } + +} diff --git a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java index 90dc6c66..226de9be 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java @@ -126,9 +126,9 @@ public class ProvisioningManager { profileKey, TrustNewIdentity.ON_FIRST_USE); - Manager m = null; + ManagerImpl m = null; try { - m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent); + m = new ManagerImpl(account, pathConfig, serviceEnvironmentConfig, userAgent); logger.debug("Refreshing pre keys"); try { @@ -178,7 +178,7 @@ public class ProvisioningManager { return false; } - final var m = new Manager(signalAccount, pathConfig, serviceEnvironmentConfig, userAgent); + final var m = new ManagerImpl(signalAccount, pathConfig, serviceEnvironmentConfig, userAgent); try (m) { m.checkAccountState(); } catch (AuthorizationFailedException ignored) { diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java index 443a7969..c42782f7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -91,18 +91,18 @@ public class RegistrationManager implements Closeable { } public static RegistrationManager init( - String username, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent + String number, File settingsPath, ServiceEnvironment serviceEnvironment, String userAgent ) throws IOException { var pathConfig = PathConfig.createDefault(settingsPath); final var serviceConfiguration = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent); - if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) { + if (!SignalAccount.userExists(pathConfig.getDataPath(), number)) { var identityKey = KeyUtils.generateIdentityKeyPair(); var registrationId = KeyHelper.generateRegistrationId(false); var profileKey = KeyUtils.createProfileKey(); var account = SignalAccount.create(pathConfig.getDataPath(), - username, + number, identityKey, registrationId, profileKey, @@ -111,7 +111,7 @@ public class RegistrationManager implements Closeable { return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent); } - var account = SignalAccount.load(pathConfig.getDataPath(), username, true, TrustNewIdentity.ON_FIRST_USE); + var account = SignalAccount.load(pathConfig.getDataPath(), number, true, TrustNewIdentity.ON_FIRST_USE); return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent); } @@ -177,21 +177,21 @@ public class RegistrationManager implements Closeable { //accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID))); account.finishRegistration(UuidUtil.parseOrNull(response.getUuid()), masterKey, pin); - Manager m = null; + ManagerImpl m = null; try { - m = new Manager(account, pathConfig, serviceEnvironmentConfig, userAgent); + m = new ManagerImpl(account, pathConfig, serviceEnvironmentConfig, userAgent); account = null; m.refreshPreKeys(); + if (response.isStorageCapable()) { + m.retrieveRemoteStorage(); + } // Set an initial empty profile so user can be added to groups try { m.setProfile(null, null, null, null, null); } catch (NoClassDefFoundError e) { logger.warn("Failed to set default profile: {}", e.getMessage()); } - if (response.isStorageCapable()) { - m.retrieveRemoteStorage(); - } final var result = m; m = null; diff --git a/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java b/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java index d506f0c6..905392c5 100644 --- a/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java +++ b/lib/src/main/java/org/asamk/signal/manager/UserAlreadyExists.java @@ -4,16 +4,16 @@ import java.io.File; public class UserAlreadyExists extends Exception { - private final String username; + private final String number; private final File fileName; - public UserAlreadyExists(String username, File fileName) { - this.username = username; + public UserAlreadyExists(String number, File fileName) { + this.number = number; this.fileName = fileName; } - public String getUsername() { - return username; + public String getNumber() { + return number; } public File getFileName() { diff --git a/lib/src/main/java/org/asamk/signal/manager/actions/SendSyncConfigurationAction.java b/lib/src/main/java/org/asamk/signal/manager/actions/SendSyncConfigurationAction.java new file mode 100644 index 00000000..0e050f0a --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/actions/SendSyncConfigurationAction.java @@ -0,0 +1,20 @@ +package org.asamk.signal.manager.actions; + +import org.asamk.signal.manager.jobs.Context; + +public class SendSyncConfigurationAction implements HandleAction { + + private static final SendSyncConfigurationAction INSTANCE = new SendSyncConfigurationAction(); + + private SendSyncConfigurationAction() { + } + + public static SendSyncConfigurationAction create() { + return INSTANCE; + } + + @Override + public void execute(Context context) throws Throwable { + context.getSyncHelper().sendConfigurationMessage(); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Device.java b/lib/src/main/java/org/asamk/signal/manager/api/Device.java index 76074cbf..9ee0d36a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Device.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Device.java @@ -6,12 +6,14 @@ public class Device { private final String name; private final long created; private final long lastSeen; + private final boolean thisDevice; - public Device(long id, String name, long created, long lastSeen) { + public Device(long id, String name, long created, long lastSeen, final boolean thisDevice) { this.id = id; this.name = name; this.created = created; this.lastSeen = lastSeen; + this.thisDevice = thisDevice; } public long getId() { @@ -29,4 +31,8 @@ public class Device { public long getLastSeen() { return lastSeen; } + + public boolean isThisDevice() { + return thisDevice; + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Group.java b/lib/src/main/java/org/asamk/signal/manager/api/Group.java new file mode 100644 index 00000000..650e10b6 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/Group.java @@ -0,0 +1,99 @@ +package org.asamk.signal.manager.api; + +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupInviteLinkUrl; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; + +import java.util.Set; + +public class Group { + + private final GroupId groupId; + private final String title; + private final String description; + private final GroupInviteLinkUrl groupInviteLinkUrl; + private final Set members; + private final Set pendingMembers; + private final Set requestingMembers; + private final Set adminMembers; + private final boolean isBlocked; + private final int messageExpirationTime; + private final boolean isAnnouncementGroup; + private final boolean isMember; + + public Group( + final GroupId groupId, + final String title, + final String description, + final GroupInviteLinkUrl groupInviteLinkUrl, + final Set members, + final Set pendingMembers, + final Set requestingMembers, + final Set adminMembers, + final boolean isBlocked, + final int messageExpirationTime, + final boolean isAnnouncementGroup, + final boolean isMember + ) { + this.groupId = groupId; + this.title = title; + this.description = description; + this.groupInviteLinkUrl = groupInviteLinkUrl; + this.members = members; + this.pendingMembers = pendingMembers; + this.requestingMembers = requestingMembers; + this.adminMembers = adminMembers; + this.isBlocked = isBlocked; + this.messageExpirationTime = messageExpirationTime; + this.isAnnouncementGroup = isAnnouncementGroup; + this.isMember = isMember; + } + + public GroupId getGroupId() { + return groupId; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public GroupInviteLinkUrl getGroupInviteLinkUrl() { + return groupInviteLinkUrl; + } + + public Set getMembers() { + return members; + } + + public Set getPendingMembers() { + return pendingMembers; + } + + public Set getRequestingMembers() { + return requestingMembers; + } + + public Set getAdminMembers() { + return adminMembers; + } + + public boolean isBlocked() { + return isBlocked; + } + + public int getMessageExpirationTime() { + return messageExpirationTime; + } + + public boolean isAnnouncementGroup() { + return isAnnouncementGroup; + } + + public boolean isMember() { + return isMember; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Identity.java b/lib/src/main/java/org/asamk/signal/manager/api/Identity.java new file mode 100644 index 00000000..4f6f21f6 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/Identity.java @@ -0,0 +1,65 @@ +package org.asamk.signal.manager.api; + +import org.asamk.signal.manager.TrustLevel; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; +import org.whispersystems.libsignal.IdentityKey; + +import java.util.Date; + +public class Identity { + + private final RecipientAddress recipient; + private final IdentityKey identityKey; + private final String safetyNumber; + private final byte[] scannableSafetyNumber; + private final TrustLevel trustLevel; + private final Date dateAdded; + + public Identity( + final RecipientAddress recipient, + final IdentityKey identityKey, + final String safetyNumber, + final byte[] scannableSafetyNumber, + final TrustLevel trustLevel, + final Date dateAdded + ) { + this.recipient = recipient; + this.identityKey = identityKey; + this.safetyNumber = safetyNumber; + this.scannableSafetyNumber = scannableSafetyNumber; + this.trustLevel = trustLevel; + this.dateAdded = dateAdded; + } + + public RecipientAddress getRecipient() { + return recipient; + } + + public IdentityKey getIdentityKey() { + return this.identityKey; + } + + public TrustLevel getTrustLevel() { + return this.trustLevel; + } + + boolean isTrusted() { + return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED; + } + + public Date getDateAdded() { + return this.dateAdded; + } + + public byte[] getFingerprint() { + return identityKey.getPublicKey().serialize(); + } + + public String getSafetyNumber() { + return safetyNumber; + } + + public byte[] getScannableSafetyNumber() { + return scannableSafetyNumber; + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java b/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java index 4a66cbb3..be1029e6 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java @@ -1,6 +1,7 @@ package org.asamk.signal.manager.api; import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.InvalidNumberException; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; @@ -12,14 +13,9 @@ public abstract class RecipientIdentifier { public static class NoteToSelf extends RecipientIdentifier { - @Override - public boolean equals(final Object obj) { - return obj instanceof NoteToSelf; - } + public static NoteToSelf INSTANCE = new NoteToSelf(); - @Override - public int hashCode() { - return 5; + private NoteToSelf() { } } @@ -34,6 +30,17 @@ public abstract class RecipientIdentifier { public static Single fromAddress(SignalServiceAddress address) { return new Uuid(address.getUuid()); } + + public static Single fromAddress(RecipientAddress address) { + if (address.getNumber().isPresent()) { + return new Number(address.getNumber().get()); + } else if (address.getUuid().isPresent()) { + return new Uuid(address.getUuid().get()); + } + throw new AssertionError("RecipientAddress without identifier"); + } + + public abstract String getIdentifier(); } public static class Uuid extends Single { @@ -58,6 +65,11 @@ public abstract class RecipientIdentifier { public int hashCode() { return uuid.hashCode(); } + + @Override + public String getIdentifier() { + return uuid.toString(); + } } public static class Number extends Single { @@ -82,6 +94,11 @@ public abstract class RecipientIdentifier { public int hashCode() { return number.hashCode(); } + + @Override + public String getIdentifier() { + return number; + } } public static class Group extends RecipientIdentifier { diff --git a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java index 7762a4cb..177f6697 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/LiveConfig.java @@ -7,6 +7,7 @@ import org.whispersystems.libsignal.ecc.ECPublicKey; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.TrustStore; import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl; +import org.whispersystems.signalservice.internal.configuration.SignalCdshUrl; import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl; import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl; import org.whispersystems.signalservice.internal.configuration.SignalProxy; @@ -38,6 +39,7 @@ class LiveConfig { private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api.directory.signal.org"; private final static String SIGNAL_KEY_BACKUP_URL = "https://api.backup.signal.org"; private final static String STORAGE_URL = "https://storage.signal.org"; + private final static String SIGNAL_CDSH_URL = ""; private final static TrustStore TRUST_STORE = new WhisperTrustStore(); private final static Optional dns = Optional.absent(); @@ -58,6 +60,7 @@ class LiveConfig { TRUST_STORE)}, new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)}, new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)}, + new SignalCdshUrl[]{new SignalCdshUrl(SIGNAL_CDSH_URL, TRUST_STORE)}, interceptors, dns, proxy, diff --git a/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java index bedec52c..d643f10a 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/SandboxConfig.java @@ -7,6 +7,7 @@ import org.whispersystems.libsignal.ecc.ECPublicKey; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.TrustStore; import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl; +import org.whispersystems.signalservice.internal.configuration.SignalCdshUrl; import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl; import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl; import org.whispersystems.signalservice.internal.configuration.SignalProxy; @@ -38,6 +39,7 @@ class SandboxConfig { private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api-staging.directory.signal.org"; private final static String SIGNAL_KEY_BACKUP_URL = "https://api-staging.backup.signal.org"; private final static String STORAGE_URL = "https://storage-staging.signal.org"; + private final static String SIGNAL_CDSH_URL = "https://cdsh.staging.signal.org"; private final static TrustStore TRUST_STORE = new WhisperTrustStore(); private final static Optional dns = Optional.absent(); @@ -58,6 +60,7 @@ class SandboxConfig { TRUST_STORE)}, new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)}, new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)}, + new SignalCdshUrl[]{new SignalCdshUrl(SIGNAL_CDSH_URL, TRUST_STORE)}, interceptors, dns, proxy, diff --git a/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java index 3677bba1..a9a08d93 100644 --- a/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java +++ b/lib/src/main/java/org/asamk/signal/manager/config/ServiceConfig.java @@ -39,7 +39,13 @@ public class ServiceConfig { logger.warn("Failed to call libzkgroup: {}", e.getMessage()); zkGroupAvailable = false; } - capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, zkGroupAvailable, true, true); + capabilities = new AccountAttributes.Capabilities(false, + zkGroupAvailable, + false, + zkGroupAvailable, + true, + true, + false); try { TrustStore contactTrustStore = new IasTrustStore(); diff --git a/lib/src/main/java/org/asamk/signal/manager/configuration/ConfigurationStore.java b/lib/src/main/java/org/asamk/signal/manager/configuration/ConfigurationStore.java new file mode 100644 index 00000000..e7e1b5f5 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/configuration/ConfigurationStore.java @@ -0,0 +1,93 @@ +package org.asamk.signal.manager.configuration; + +public class ConfigurationStore { + + private final Saver saver; + + private Boolean readReceipts; + private Boolean unidentifiedDeliveryIndicators; + private Boolean typingIndicators; + private Boolean linkPreviews; + + public ConfigurationStore(final Saver saver) { + this.saver = saver; + } + + public static ConfigurationStore fromStorage(Storage storage, Saver saver) { + final var store = new ConfigurationStore(saver); + store.readReceipts = storage.readReceipts; + store.unidentifiedDeliveryIndicators = storage.unidentifiedDeliveryIndicators; + store.typingIndicators = storage.typingIndicators; + store.linkPreviews = storage.linkPreviews; + return store; + } + + public Boolean getReadReceipts() { + return readReceipts; + } + + public void setReadReceipts(final boolean readReceipts) { + this.readReceipts = readReceipts; + saver.save(toStorage()); + } + + public Boolean getUnidentifiedDeliveryIndicators() { + return unidentifiedDeliveryIndicators; + } + + public void setUnidentifiedDeliveryIndicators(final boolean unidentifiedDeliveryIndicators) { + this.unidentifiedDeliveryIndicators = unidentifiedDeliveryIndicators; + saver.save(toStorage()); + } + + public Boolean getTypingIndicators() { + return typingIndicators; + } + + public void setTypingIndicators(final boolean typingIndicators) { + this.typingIndicators = typingIndicators; + saver.save(toStorage()); + } + + public Boolean getLinkPreviews() { + return linkPreviews; + } + + public void setLinkPreviews(final boolean linkPreviews) { + this.linkPreviews = linkPreviews; + saver.save(toStorage()); + } + + private Storage toStorage() { + return new Storage(readReceipts, unidentifiedDeliveryIndicators, typingIndicators, linkPreviews); + } + + public static final class Storage { + + public Boolean readReceipts; + public Boolean unidentifiedDeliveryIndicators; + public Boolean typingIndicators; + public Boolean linkPreviews; + + // For deserialization + private Storage() { + } + + public Storage( + final Boolean readReceipts, + final Boolean unidentifiedDeliveryIndicators, + final Boolean typingIndicators, + final Boolean linkPreviews + ) { + this.readReceipts = readReceipts; + this.unidentifiedDeliveryIndicators = unidentifiedDeliveryIndicators; + this.typingIndicators = typingIndicators; + this.linkPreviews = linkPreviews; + } + } + + public interface Saver { + + void save(Storage storage); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 3ddd6edd..62f4f111 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -145,7 +145,10 @@ public class GroupHelper { groupMasterKey); } if (group == null) { - group = groupV2Helper.getDecryptedGroup(groupSecretParams); + try { + group = groupV2Helper.getDecryptedGroup(groupSecretParams); + } catch (NotAGroupMemberException ignored) { + } } if (group != null) { storeProfileKeysFromMembers(group); @@ -348,10 +351,20 @@ public class GroupHelper { private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) { final var group = account.getGroupStore().getGroup(groupId); - if (group instanceof GroupInfoV2 && (forceUpdate || ((GroupInfoV2) group).getGroup() == null)) { - final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey()); - ((GroupInfoV2) group).setGroup(groupV2Helper.getDecryptedGroup(groupSecretParams), recipientResolver); - account.getGroupStore().updateGroup(group); + if (group instanceof GroupInfoV2) { + final var groupInfoV2 = (GroupInfoV2) group; + if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + DecryptedGroup decryptedGroup; + try { + decryptedGroup = groupV2Helper.getDecryptedGroup(groupSecretParams); + } catch (NotAGroupMemberException e) { + groupInfoV2.setPermissionDenied(true); + decryptedGroup = null; + } + groupInfoV2.setGroup(decryptedGroup, recipientResolver); + account.getGroupStore().updateGroup(group); + } } return group; } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index 3187fca1..746af2f9 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -6,6 +6,7 @@ import org.asamk.signal.manager.groups.GroupLinkPassword; import org.asamk.signal.manager.groups.GroupLinkState; import org.asamk.signal.manager.groups.GroupPermission; import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.recipients.Profile; import org.asamk.signal.manager.storage.recipients.RecipientId; @@ -35,6 +36,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.File; @@ -78,10 +80,16 @@ public class GroupV2Helper { this.addressResolver = addressResolver; } - public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) { + public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException { try { final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams); return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString); + } catch (NonSuccessfulResponseCodeException e) { + if (e.getCode() == 403) { + throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null); + } + logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage()); + return null; } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage()); return null; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 0917a214..16f47d3c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -15,6 +15,7 @@ import org.asamk.signal.manager.actions.SendGroupInfoRequestAction; import org.asamk.signal.manager.actions.SendReceiptAction; import org.asamk.signal.manager.actions.SendRetryMessageRequestAction; import org.asamk.signal.manager.actions.SendSyncBlockedListAction; +import org.asamk.signal.manager.actions.SendSyncConfigurationAction; import org.asamk.signal.manager.actions.SendSyncContactsAction; import org.asamk.signal.manager.actions.SendSyncGroupsAction; import org.asamk.signal.manager.actions.SendSyncKeysAction; @@ -144,7 +145,8 @@ public final class IncomingMessageHandler { final var sender = account.getRecipientStore().resolveRecipient(e.getSender()); final var senderProfile = profileProvider.getProfile(sender); final var selfProfile = profileProvider.getProfile(account.getSelfRecipientId()); - if (senderProfile != null + if (e.getSenderDevice() != account.getDeviceId() + && senderProfile != null && senderProfile.getCapabilities().contains(Profile.Capability.senderKey) && selfProfile != null && selfProfile.getCapabilities().contains(Profile.Capability.senderKey)) { @@ -270,7 +272,9 @@ public final class IncomingMessageHandler { if (rm.isKeysRequest()) { actions.add(SendSyncKeysAction.create()); } - // TODO Handle rm.isConfigurationRequest(); + if (rm.isConfigurationRequest()) { + actions.add(SendSyncConfigurationAction.create()); + } } if (syncMessage.getGroups().isPresent()) { logger.warn("Received a group v1 sync message, that can't be handled anymore, ignoring."); @@ -352,7 +356,13 @@ public final class IncomingMessageHandler { } } if (syncMessage.getConfiguration().isPresent()) { - // TODO + final var configurationMessage = syncMessage.getConfiguration().get(); + final var configurationStore = account.getConfigurationStore(); + configurationStore.setReadReceipts(configurationMessage.getReadReceipts().orNull()); + configurationStore.setLinkPreviews(configurationMessage.getLinkPreviews().orNull()); + configurationStore.setTypingIndicators(configurationMessage.getTypingIndicators().orNull()); + configurationStore.setUnidentifiedDeliveryIndicators(configurationMessage.getUnidentifiedDeliveryIndicators() + .orNull()); } return actions; } @@ -415,7 +425,7 @@ public final class IncomingMessageHandler { } final var recipientId = recipientResolver.resolveRecipient(source); - if (!group.isMember(recipientId)) { + if (!group.isMember(recipientId) && !(group.isPendingMember(recipientId) && message.isGroupV2Update())) { return true; } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java index d4f8ae5d..e24d41fa 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java @@ -32,6 +32,8 @@ import java.nio.file.Files; import java.util.Base64; import java.util.Date; import java.util.HashSet; +import java.util.List; +import java.util.Objects; import java.util.Set; import io.reactivex.rxjava3.core.Single; @@ -109,6 +111,17 @@ public final class ProfileHelper { */ public void setProfile( String givenName, final String familyName, String about, String aboutEmoji, Optional avatar + ) throws IOException { + setProfile(true, givenName, familyName, about, aboutEmoji, avatar); + } + + public void setProfile( + boolean uploadProfile, + 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); @@ -126,17 +139,22 @@ public final class ProfileHelper { } var newProfile = builder.build(); - try (final var streamDetails = avatar == null - ? avatarStore.retrieveProfileAvatar(account.getSelfAddress()) - : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) { - dependencies.getAccountManager() - .setVersionedProfile(account.getUuid(), - account.getProfileKey(), - newProfile.getInternalServiceName(), - newProfile.getAbout() == null ? "" : newProfile.getAbout(), - newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(), - Optional.absent(), - streamDetails); + if (uploadProfile) { + try (final var streamDetails = avatar == null + ? avatarStore.retrieveProfileAvatar(account.getSelfAddress()) + : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) { + final var avatarPath = dependencies.getAccountManager() + .setVersionedProfile(account.getUuid(), + account.getProfileKey(), + newProfile.getInternalServiceName(), + newProfile.getAbout() == null ? "" : newProfile.getAbout(), + newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(), + Optional.absent(), + streamDetails, + List.of(/* TODO */)); + builder.withAvatarUrlPath(avatarPath.orNull()); + newProfile = builder.build(); + } } if (avatar != null) { @@ -195,6 +213,7 @@ public final class ProfileHelper { null, null, null, + null, ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null), ProfileUtils.getCapabilities(encryptedProfile)); } @@ -240,15 +259,23 @@ public final class ProfileHelper { private Profile decryptProfileAndDownloadAvatar( final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile ) { - if (encryptedProfile.getAvatar() != null) { - downloadProfileAvatar(addressResolver.resolveSignalServiceAddress(recipientId), - encryptedProfile.getAvatar(), - profileKey); - } + final var avatarPath = encryptedProfile.getAvatar(); + downloadProfileAvatar(recipientId, avatarPath, profileKey); return ProfileUtils.decryptProfile(profileKey, encryptedProfile); } + public void downloadProfileAvatar( + final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey + ) { + var profile = account.getProfileStore().getProfile(recipientId); + if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) { + downloadProfileAvatar(addressResolver.resolveSignalServiceAddress(recipientId), avatarPath, profileKey); + var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile); + account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build()); + } + } + private ProfileAndCredential retrieveProfileSync( RecipientId recipientId, SignalServiceProfile.RequestType requestType ) throws IOException { @@ -308,6 +335,15 @@ public final class ProfileHelper { private void downloadProfileAvatar( SignalServiceAddress address, String avatarPath, ProfileKey profileKey ) { + if (avatarPath == null) { + try { + avatarStore.deleteProfileAvatar(address); + } catch (IOException e) { + logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage()); + } + return; + } + try { avatarStore.storeProfileAvatar(address, outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream)); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java index 4caab519..b68e65b4 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java @@ -32,13 +32,18 @@ public class StorageHelper { private final SignalAccount account; private final SignalDependencies dependencies; private final GroupHelper groupHelper; + private final ProfileHelper profileHelper; public StorageHelper( - final SignalAccount account, final SignalDependencies dependencies, final GroupHelper groupHelper + final SignalAccount account, + final SignalDependencies dependencies, + final GroupHelper groupHelper, + final ProfileHelper profileHelper ) { this.account = account; this.dependencies = dependencies; this.groupHelper = groupHelper; + this.profileHelper = profileHelper; } public void readDataFromStorage() throws IOException { @@ -188,13 +193,37 @@ public class StorageHelper { return; } + if (!accountRecord.getE164().equals(account.getUsername())) { + // TODO implement changed number handling + } + + account.getConfigurationStore().setReadReceipts(accountRecord.isReadReceiptsEnabled()); + account.getConfigurationStore().setTypingIndicators(accountRecord.isTypingIndicatorsEnabled()); + account.getConfigurationStore() + .setUnidentifiedDeliveryIndicators(accountRecord.isSealedSenderIndicatorsEnabled()); + account.getConfigurationStore().setLinkPreviews(accountRecord.isLinkPreviewsEnabled()); + if (accountRecord.getProfileKey().isPresent()) { + ProfileKey profileKey; try { - account.setProfileKey(new ProfileKey(accountRecord.getProfileKey().get())); + profileKey = new ProfileKey(accountRecord.getProfileKey().get()); } catch (InvalidInputException e) { logger.warn("Received invalid profile key from storage"); + profileKey = null; + } + if (profileKey != null) { + account.setProfileKey(profileKey); + final var avatarPath = accountRecord.getAvatarUrlPath().orNull(); + profileHelper.downloadProfileAvatar(account.getSelfRecipientId(), avatarPath, profileKey); } } + + profileHelper.setProfile(false, + accountRecord.getGivenName().orNull(), + accountRecord.getFamilyName().orNull(), + null, + null, + null); } private SignalStorageRecord getSignalStorageRecord(final StorageId accountId) throws IOException { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java index 6db1ca7d..e3fc7fc2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java @@ -14,6 +14,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsInputStream; @@ -221,6 +222,15 @@ public class SyncHelper { sendHelper.sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage)); } + public void sendConfigurationMessage() throws IOException { + final var config = account.getConfigurationStore(); + var configurationMessage = new ConfigurationMessage(Optional.fromNullable(config.getReadReceipts()), + Optional.fromNullable(config.getUnidentifiedDeliveryIndicators()), + Optional.fromNullable(config.getTypingIndicators()), + Optional.fromNullable(config.getLinkPreviews())); + sendHelper.sendSyncMessage(SignalServiceSyncMessage.forConfiguration(configurationMessage)); + } + public void handleSyncDeviceContacts(final InputStream input) throws IOException { final var s = new DeviceContactsInputStream(input); DeviceContact c; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index fd4ec597..5bb9fdeb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.asamk.signal.manager.TrustLevel; +import org.asamk.signal.manager.configuration.ConfigurationStore; import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.storage.contacts.ContactsStore; import org.asamk.signal.manager.storage.contacts.LegacyJsonContactsStore; @@ -103,6 +104,8 @@ public class SignalAccount implements Closeable { private RecipientStore recipientStore; private StickerStore stickerStore; private StickerStore.Storage stickerStoreStorage; + private ConfigurationStore configurationStore; + private ConfigurationStore.Storage configurationStoreStorage; private MessageCache messageCache; @@ -159,6 +162,7 @@ public class SignalAccount implements Closeable { account.recipientStore, account::saveGroupStore); account.stickerStore = new StickerStore(account::saveStickerStore); + account.configurationStore = new ConfigurationStore(account::saveConfigurationStore); account.registered = false; @@ -267,6 +271,7 @@ public class SignalAccount implements Closeable { account.recipientStore, account::saveGroupStore); account.stickerStore = new StickerStore(account::saveStickerStore); + account.configurationStore = new ConfigurationStore(account::saveConfigurationStore); account.recipientStore.resolveRecipientTrusted(account.getSelfAddress()); account.migrateLegacyConfigs(); @@ -491,6 +496,15 @@ public class SignalAccount implements Closeable { stickerStore = new StickerStore(this::saveStickerStore); } + if (rootNode.hasNonNull("configurationStore")) { + configurationStoreStorage = jsonProcessor.convertValue(rootNode.get("configurationStore"), + ConfigurationStore.Storage.class); + configurationStore = ConfigurationStore.fromStorage(configurationStoreStorage, + this::saveConfigurationStore); + } else { + configurationStore = new ConfigurationStore(this::saveConfigurationStore); + } + migratedLegacyConfig = loadLegacyThreadStore(rootNode) || migratedLegacyConfig; if (migratedLegacyConfig) { @@ -617,6 +631,7 @@ public class SignalAccount implements Closeable { profile.getFamilyName(), profile.getAbout(), profile.getAboutEmoji(), + null, profile.isUnrestrictedUnidentifiedAccess() ? Profile.UnidentifiedAccessMode.UNRESTRICTED : profile.getUnidentifiedAccess() != null @@ -677,6 +692,11 @@ public class SignalAccount implements Closeable { save(); } + private void saveConfigurationStore(ConfigurationStore.Storage storage) { + this.configurationStoreStorage = storage; + save(); + } + private void save() { synchronized (fileChannel) { var rootNode = jsonProcessor.createObjectNode(); @@ -707,7 +727,8 @@ public class SignalAccount implements Closeable { profileKey == null ? null : Base64.getEncoder().encodeToString(profileKey.serialize())) .put("registered", registered) .putPOJO("groupStore", groupStoreStorage) - .putPOJO("stickerStore", stickerStoreStorage); + .putPOJO("stickerStore", stickerStoreStorage) + .putPOJO("configurationStore", configurationStoreStorage); try { try (var output = new ByteArrayOutputStream()) { // Write to memory first to prevent corrupting the file in case of serialization errors @@ -797,6 +818,10 @@ public class SignalAccount implements Closeable { return senderKeyStore; } + public ConfigurationStore getConfigurationStore() { + return configurationStore; + } + public MessageCache getMessageCache() { return messageCache; } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java index f86dcb04..a06b83df 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java @@ -22,16 +22,23 @@ public class GroupInfoV2 extends GroupInfo { private boolean blocked; private DecryptedGroup group; // stored as a file with hexadecimal groupId as name private RecipientResolver recipientResolver; + private boolean permissionDenied; public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) { this.groupId = groupId; this.masterKey = masterKey; } - public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey, final boolean blocked) { + public GroupInfoV2( + final GroupIdV2 groupId, + final GroupMasterKey masterKey, + final boolean blocked, + final boolean permissionDenied + ) { this.groupId = groupId; this.masterKey = masterKey; this.blocked = blocked; + this.permissionDenied = permissionDenied; } @Override @@ -44,6 +51,9 @@ public class GroupInfoV2 extends GroupInfo { } public void setGroup(final DecryptedGroup group, final RecipientResolver recipientResolver) { + if (group != null) { + this.permissionDenied = false; + } this.group = group; this.recipientResolver = recipientResolver; } @@ -151,4 +161,12 @@ public class GroupInfoV2 extends GroupInfo { public boolean isAnnouncementGroup() { return this.group != null && this.group.getIsAnnouncementGroup() == EnabledState.ENABLED; } + + public void setPermissionDenied(final boolean permissionDenied) { + this.permissionDenied = permissionDenied; + } + + public boolean isPermissionDenied() { + return permissionDenied; + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java index 4adc413a..fe8f85a6 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java @@ -104,7 +104,7 @@ public class GroupStore { throw new AssertionError("Invalid master key for group " + groupId.toBase64()); } - return new GroupInfoV2(groupId, masterKey, g2.blocked); + return new GroupInfoV2(groupId, masterKey, g2.blocked, g2.permissionDenied); }).collect(Collectors.toMap(GroupInfo::getGroupId, g -> g)); return new GroupStore(groupCachePath, groups, recipientResolver, saver); @@ -268,13 +268,13 @@ public class GroupStore { final var g2 = (GroupInfoV2) g; return new Storage.GroupV2(g2.getGroupId().toBase64(), Base64.getEncoder().encodeToString(g2.getMasterKey().serialize()), - g2.isBlocked()); + g2.isBlocked(), + g2.isPermissionDenied()); }).collect(Collectors.toList())); } public static class Storage { - // @JsonSerialize(using = GroupsSerializer.class) @JsonDeserialize(using = GroupsDeserializer.class) public List groups; @@ -408,46 +408,24 @@ public class GroupStore { public String groupId; public String masterKey; public boolean blocked; + public boolean permissionDenied; // For deserialization private GroupV2() { } - public GroupV2(final String groupId, final String masterKey, final boolean blocked) { + public GroupV2( + final String groupId, final String masterKey, final boolean blocked, final boolean permissionDenied + ) { this.groupId = groupId; this.masterKey = masterKey; this.blocked = blocked; + this.permissionDenied = permissionDenied; } } } - // private static class GroupsSerializer extends JsonSerializer> { -// -// @Override -// public void serialize( -// final List groups, final JsonGenerator jgen, final SerializerProvider provider -// ) throws IOException { -// jgen.writeStartArray(groups.size()); -// for (var group : groups) { -// if (group instanceof GroupInfoV1) { -// jgen.writeObject(group); -// } else if (group instanceof GroupInfoV2) { -// final var groupV2 = (GroupInfoV2) group; -// jgen.writeStartObject(); -// jgen.writeStringField("groupId", groupV2.getGroupId().toBase64()); -// jgen.writeStringField("masterKey", -// Base64.getEncoder().encodeToString(groupV2.getMasterKey().serialize())); -// jgen.writeBooleanField("blocked", groupV2.isBlocked()); -// jgen.writeEndObject(); -// } else { -// throw new AssertionError("Unknown group version"); -// } -// } -// jgen.writeEndArray(); -// } -// } -// private static class GroupsDeserializer extends JsonDeserializer> { @Override diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/Profile.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/Profile.java index d61a81b5..c6ba5c92 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/Profile.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/Profile.java @@ -17,6 +17,8 @@ public class Profile { private final String aboutEmoji; + private final String avatarUrlPath; + private final UnidentifiedAccessMode unidentifiedAccessMode; private final Set capabilities; @@ -27,6 +29,7 @@ public class Profile { final String familyName, final String about, final String aboutEmoji, + final String avatarUrlPath, final UnidentifiedAccessMode unidentifiedAccessMode, final Set capabilities ) { @@ -35,6 +38,7 @@ public class Profile { this.familyName = familyName; this.about = about; this.aboutEmoji = aboutEmoji; + this.avatarUrlPath = avatarUrlPath; this.unidentifiedAccessMode = unidentifiedAccessMode; this.capabilities = capabilities; } @@ -45,6 +49,7 @@ public class Profile { familyName = builder.familyName; about = builder.about; aboutEmoji = builder.aboutEmoji; + avatarUrlPath = builder.avatarUrlPath; unidentifiedAccessMode = builder.unidentifiedAccessMode; capabilities = builder.capabilities; } @@ -60,6 +65,7 @@ public class Profile { builder.familyName = copy.getFamilyName(); builder.about = copy.getAbout(); builder.aboutEmoji = copy.getAboutEmoji(); + builder.avatarUrlPath = copy.getAvatarUrlPath(); builder.unidentifiedAccessMode = copy.getUnidentifiedAccessMode(); builder.capabilities = copy.getCapabilities(); return builder; @@ -107,6 +113,10 @@ public class Profile { return aboutEmoji; } + public String getAvatarUrlPath() { + return avatarUrlPath; + } + public UnidentifiedAccessMode getUnidentifiedAccessMode() { return unidentifiedAccessMode; } @@ -152,6 +162,7 @@ public class Profile { private String familyName; private String about; private String aboutEmoji; + private String avatarUrlPath; private UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.UNKNOWN; private Set capabilities = Collections.emptySet(); private long lastUpdateTimestamp = 0; @@ -179,6 +190,11 @@ public class Profile { return this; } + public Builder withAvatarUrlPath(final String val) { + avatarUrlPath = val; + return this; + } + public Builder withUnidentifiedAccessMode(final UnidentifiedAccessMode val) { unidentifiedAccessMode = val; return this; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java index 88877d83..c0f5b0b8 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java @@ -57,6 +57,16 @@ public class RecipientAddress { } } + public String getLegacyIdentifier() { + if (e164.isPresent()) { + return e164.get(); + } else if (uuid.isPresent()) { + return uuid.get().toString(); + } else { + throw new AssertionError("Given the checks in the constructor, this should not be possible."); + } + } + public boolean matches(RecipientAddress other) { return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || ( e164.isPresent() && other.e164.isPresent() && e164.get().equals(other.e164.get()) diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java index bace6a6b..16302692 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java @@ -89,6 +89,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile r.profile.familyName, r.profile.about, r.profile.aboutEmoji, + r.profile.avatarUrlPath, Profile.UnidentifiedAccessMode.valueOfOrUnknown(r.profile.unidentifiedAccessMode), r.profile.capabilities.stream() .map(Profile.Capability::valueOfOrNull) @@ -445,6 +446,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile recipient.getProfile().getFamilyName(), recipient.getProfile().getAbout(), recipient.getProfile().getAboutEmoji(), + recipient.getProfile().getAvatarUrlPath(), recipient.getProfile().getUnidentifiedAccessMode().name(), recipient.getProfile() .getCapabilities() @@ -558,6 +560,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile public String familyName; public String about; public String aboutEmoji; + public String avatarUrlPath; public String unidentifiedAccessMode; public Set capabilities; @@ -571,6 +574,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile final String familyName, final String about, final String aboutEmoji, + final String avatarUrlPath, final String unidentifiedAccessMode, final Set capabilities ) { @@ -579,6 +583,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile this.familyName = familyName; this.about = about; this.aboutEmoji = aboutEmoji; + this.avatarUrlPath = avatarUrlPath; this.unidentifiedAccessMode = unidentifiedAccessMode; this.capabilities = capabilities; } diff --git a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java index 7ceb07f6..c1b1183c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java @@ -27,6 +27,7 @@ public class ProfileUtils { nameParts.second(), about, aboutEmoji, + encryptedProfile.getAvatar(), getUnidentifiedAccessMode(encryptedProfile, profileCipher), getCapabilities(encryptedProfile)); } catch (InvalidCiphertextException e) { diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index 545f3676..9eb0a90c 100755 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -44,6 +44,64 @@ Phone numbers always have the format + == Methods +=== Control methods +These methods are available if the daemon is started anonymously (without an explicit `-u USERNAME`). +Requests are sent to `/org/asamk/Signal`; requests related to individual accounts are sent to +`/org/asamk/Signal/_441234567890` where the + dialing code is replaced by an underscore (_). +Only `version()` is activated in single-user mode; the rest are disabled. + +link() -> deviceLinkUri:: +link(newDeviceName) -> deviceLinkUri:: +* newDeviceName : Name to give new device (defaults to "cli" if no name is given) +* deviceLinkUri : URI of newly linked device + +Returns a URI of the form "tsdevice:/?uuid=...". This can be piped to a QR encoder to create a display that +can be captured by a Signal smartphone client. For example: + +`dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client"|tr '\n' '\0'|sed 's/.*string //g'|sed 's/\"//g'|qrencode -s10 -tANSI256` + +Exception: Failure + +listAccounts() -> accountList:: +* accountList : Array of all attached accounts in DBus object path form + +Exceptions: None + +register(number, voiceVerification) -> <>:: +* number : Phone number +* voiceVerification : true = use voice verification; false = use SMS verification + +Exceptions: Failure, InvalidNumber, RequiresCaptcha + +registerWithCaptcha(number, voiceVerification, captcha) -> <>:: +* number : Phone number +* voiceVerification : true = use voice verification; false = use SMS verification +* captcha : Captcha string + +Exceptions: Failure, InvalidNumber, RequiresCaptcha + +verify(number, verificationCode) -> <>:: +* number : Phone number +* verificationCode : Code received from Signal after successful registration request + +Command fails if PIN was set after previous registration; use verifyWithPin instead. + +Exception: Failure, InvalidNumber + +verifyWithPin(number, verificationCode, pin) -> <>:: +* number : Phone number +* verificationCode : Code received from Signal after successful registration request +* pin : PIN you set with setPin command after verifying previous registration + +Exception: Failure, InvalidNumber + +version() -> version:: +* version : Version string of signal-cli + +Exceptions: None + +=== Other methods + updateGroup(groupId, newName, members, avatar) -> groupId:: * groupId : Byte array representing the internal group identifier * newName : New name of group (empty if unchanged) @@ -52,8 +110,11 @@ updateGroup(groupId, newName, members, avatar) -> groupId:: Exceptions: AttachmentInvalid, Failure, InvalidNumber, GroupNotFound -updateProfile(newName, about , aboutEmoji , avatar, remove) -> <>:: -* newName : New name for your own profile (empty if unchanged) +updateProfile(name, about, aboutEmoji , avatar, remove) -> <>:: +updateProfile(givenName, familyName, about, aboutEmoji , avatar, remove) -> <>:: +* name : Name for your own profile (empty if unchanged) +* givenName : Given name for your own profile (empty if unchanged) +* familyName : Family name for your own profile (empty if unchanged) * about : About message for profile (empty if unchanged) * aboutEmoji : Emoji for profile (empty if unchanged) * avatar : Filename of avatar picture for profile (empty if unchanged) @@ -61,6 +122,12 @@ updateProfile(newName, about , aboutEmoji , avatar, remove) -> <> Exceptions: Failure +setExpirationTimer(number, expiration) -> <>:: +* number : Phone number of recipient +* expiration : int32 for the number of seconds before messages to this recipient disappear. Set to 0 to disable expiration. + +Exceptions: Failure + setContactBlocked(number, block) -> <>:: * number : Phone number affected by method * block : false=remove block , true=block @@ -107,6 +174,18 @@ sendGroupMessage(message, attachments, groupId) -> timestamp:: Exceptions: GroupNotFound, Failure, AttachmentInvalid +sendContacts() -> <>:: + +Sends a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device. + +Exceptions: Failure + +sendSyncRequest() -> <>:: + +Sends a synchronization request to the primary device (for group, contacts, ...). Only works if sent from a secondary device. + +Exception: Failure + sendNoteToSelfMessage(message, attachments) -> timestamp:: * message : Text to send (can be UTF8) * attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under) @@ -229,11 +308,59 @@ isGroupBlocked(groupId) -> state:: Exceptions: None; for unknown groups false is returned +removePin() -> <>:: + +Removes registration PIN protection. + +Exception: Failure + +setPin(pin) -> <>:: +* pin : PIN you set after registration (resets after 7 days of inactivity) + +Sets a registration lock PIN, to prevent others from registering your number. + +Exception: Failure + version() -> version:: * version : Version string of signal-cli -isRegistred -> result:: -* result : Currently always returns true +isRegistered() -> result:: +isRegistered(number) -> result:: +isRegistered(numbers) -> results:: +* number : Phone number +* numbers : String array of phone numbers +* result : true=number is registered, false=number is not registered +* results : Boolean array of results + +Exception: InvalidNumber for an incorrectly formatted phone number. For unknown numbers, false is returned, but no exception is raised. If no number is given, returns whether you are registered (presumably true). + +addDevice(deviceUri) -> <>:: +* deviceUri : URI in the form of tsdevice:/?uuid=... Normally received from Signal desktop or smartphone app + +Exception: InvalidUri + +listDevices() -> devices:: +* devices : String array of linked devices + +Exception: Failure + +removeDevice(deviceId) -> <>:: +* deviceId : Device ID to remove, obtained from listDevices() command + +Exception: Failure + +updateDeviceName(deviceName) -> <>:: +* deviceName : New name + +Set a new name for this device (main or linked). + +Exception: Failure + +uploadStickerPack(stickerPackPath) -> url:: +* stickerPackPath : Path to the manifest.json file or a zip file in the same directory +* url : URL of sticker pack after successful upload + +Exception: Failure == Signals diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 573ade7c..9829fe00 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -113,6 +113,23 @@ Can fix problems with receiving messages. *-n* NAME, *--device-name* NAME:: Set a new device name for the main or linked device +=== updateConfiguration + +Update signal configs and sync them to linked devices. +This command only works on the main devices. + +*--read-receipts* {true,false}:: +Indicates if Signal should send read receipts. + +*--unidentified-delivery-indicators* {true,false}:: +Indicates if Signal should show unidentified delivery indicators. + +*--typing-indicators* {true,false}:: +Indicates if Signal should send/show typing indicators. + +*--link-previews* {true,false}:: +Indicates if Signal should generate link previews. + === setPin Set a registration lock pin, to prevent others from registering this number. diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index e0f67888..65ed3d21 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -2,9 +2,12 @@ package org.asamk; import org.asamk.signal.dbus.DbusAttachment; import org.asamk.signal.dbus.DbusMention; +import org.freedesktop.dbus.DBusPath; +import org.freedesktop.dbus.annotations.DBusProperty; import org.freedesktop.dbus.exceptions.DBusException; import org.freedesktop.dbus.exceptions.DBusExecutionException; import org.freedesktop.dbus.interfaces.DBusInterface; +import org.freedesktop.dbus.interfaces.Properties; import org.freedesktop.dbus.messages.DBusSignal; import java.util.ArrayList; @@ -17,6 +20,8 @@ import java.util.Map; */ public interface Signal extends DBusInterface { + String getSelfNumber(); + long sendMessage( String message, List attachmentNames, String recipient ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UntrustedIdentity; @@ -30,7 +35,7 @@ public interface Signal extends DBusInterface { ) throws Error.Failure, Error.GroupNotFound, Error.UntrustedIdentity; void sendReadReceipt( - String recipient, List targetSentTimestamp + String recipient, List messageIds ) throws Error.Failure, Error.UntrustedIdentity; long sendRemoteDeleteMessage( @@ -53,6 +58,10 @@ public interface Signal extends DBusInterface { String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, List recipients ) throws Error.InvalidNumber, Error.Failure; + void sendContacts() throws Error.Failure; + + void sendSyncRequest() throws Error.Failure; + long sendNoteToSelfMessage( String message, List attachmentNames ) throws Error.AttachmentInvalid, Error.Failure; @@ -71,6 +80,8 @@ public interface Signal extends DBusInterface { void setContactName(String number, String name) throws Error.InvalidNumber; + void setExpirationTimer(final String number, final int expiration) throws Error.Failure; + void setContactBlocked(String number, boolean blocked) throws Error.InvalidNumber; void setGroupBlocked(byte[] groupId, boolean blocked) throws Error.GroupNotFound, Error.InvalidGroupId; @@ -87,10 +98,37 @@ public interface Signal extends DBusInterface { byte[] groupId, String name, List members, String avatar ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.InvalidGroupId; + boolean isRegistered() throws Error.Failure, Error.InvalidNumber; + + boolean isRegistered(String number) throws Error.Failure, Error.InvalidNumber; + + List isRegistered(List numbers) throws Error.Failure, Error.InvalidNumber; + + void addDevice(String uri) throws Error.InvalidUri; + + DBusPath getDevice(long deviceId); + + List listDevices() throws Error.Failure; + + DBusPath getThisDevice(); + + void updateProfile( + String givenName, + String familyName, + String about, + String aboutEmoji, + String avatarPath, + boolean removeAvatar + ) throws Error.Failure; + void updateProfile( String name, String about, String aboutEmoji, String avatarPath, boolean removeAvatar ) throws Error.Failure; + void removePin(); + + void setPin(String registrationLockPin); + String version(); List listNumbers(); @@ -107,6 +145,8 @@ public interface Signal extends DBusInterface { byte[] joinGroup(final String groupLink) throws Error.Failure; + String uploadStickerPack(String stickerPackPath) throws Error.Failure; + class MessageReceived extends DBusSignal { private final long timestamp; @@ -379,6 +419,15 @@ public interface Signal extends DBusInterface { } } + @DBusProperty(name = "Id", type = Integer.class, access = DBusProperty.Access.READ) + @DBusProperty(name = "Name", type = String.class) + @DBusProperty(name = "Created", type = String.class, access = DBusProperty.Access.READ) + @DBusProperty(name = "LastSeen", type = String.class, access = DBusProperty.Access.READ) + interface Device extends DBusInterface, Properties { + + void removeDevice() throws Error.Failure; + } + interface Error { class AttachmentInvalid extends DBusExecutionException { @@ -388,6 +437,13 @@ public interface Signal extends DBusInterface { } } + class InvalidUri extends DBusExecutionException { + + public InvalidUri(final String message) { + super(message); + } + } + class Failure extends DBusExecutionException { public Failure(final String message) { diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index 4aa510d6..3d35ff8f 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -8,7 +8,6 @@ import net.sourceforge.argparse4j.inf.Namespace; import org.asamk.Signal; import org.asamk.signal.commands.Command; import org.asamk.signal.commands.Commands; -import org.asamk.signal.commands.DbusCommand; import org.asamk.signal.commands.ExtendedDbusCommand; import org.asamk.signal.commands.LocalCommand; import org.asamk.signal.commands.MultiLocalCommand; @@ -19,6 +18,7 @@ import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.dbus.DbusManagerImpl; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.NotRegisteredException; import org.asamk.signal.manager.ProvisioningManager; @@ -29,6 +29,7 @@ import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.util.IOUtils; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; +import org.freedesktop.dbus.exceptions.DBusExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; @@ -116,8 +117,8 @@ public class App { var username = ns.getString("username"); - final var useDbus = ns.getBoolean("dbus"); - final var useDbusSystem = ns.getBoolean("dbus-system"); + final var useDbus = Boolean.TRUE.equals(ns.getBoolean("dbus")); + final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system")); if (useDbus || useDbusSystem) { // If username is null, it will connect to the default object path initDbusClient(command, username, useDbusSystem, outputWriter); @@ -161,7 +162,7 @@ public class App { } if (username == null) { - var usernames = Manager.getAllLocalUsernames(dataPath); + var usernames = Manager.getAllLocalNumbers(dataPath); if (command instanceof MultiLocalCommand) { handleMultiLocalCommand((MultiLocalCommand) command, @@ -346,8 +347,14 @@ public class App { ) throws CommandException { if (command instanceof ExtendedDbusCommand) { ((ExtendedDbusCommand) command).handleCommand(ns, ts, dBusConn, outputWriter); - } else if (command instanceof DbusCommand) { - ((DbusCommand) command).handleCommand(ns, ts, outputWriter); + } else if (command instanceof LocalCommand) { + try { + ((LocalCommand) command).handleCommand(ns, new DbusManagerImpl(ts, dBusConn), outputWriter); + } catch (UnsupportedOperationException e) { + throw new UserErrorException("Command is not yet implemented via dbus", e); + } catch (DBusExecutionException e) { + throw new UnexpectedErrorException(e.getMessage(), e); + } } else { throw new UserErrorException("Command is not yet implemented via dbus"); } diff --git a/src/main/java/org/asamk/signal/Main.java b/src/main/java/org/asamk/signal/Main.java index 26079ec6..2a95e6de 100644 --- a/src/main/java/org/asamk/signal/Main.java +++ b/src/main/java/org/asamk/signal/Main.java @@ -80,7 +80,7 @@ public class Main { return false; } - return ns.getBoolean("verbose"); + return Boolean.TRUE.equals(ns.getBoolean("verbose")); } private static void configureLogging(final boolean verbose) { diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index bc9244f8..35790678 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -61,13 +61,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { final var recipientName = getLegacyIdentifier(m.resolveSignalServiceAddress(e.getSender())); writer.println( "Use 'signal-cli -u {} listIdentities -n {}', verify the key and run 'signal-cli -u {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted", - m.getUsername(), + m.getSelfNumber(), recipientName, - m.getUsername(), + m.getSelfNumber(), recipientName); writer.println( "If you don't care about security, use 'signal-cli -u {} trust -a {}' to trust it without verification", - m.getUsername(), + m.getSelfNumber(), recipientName); } else { writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName()); @@ -657,7 +657,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { private void printMention( PlainTextWriter writer, SignalServiceDataMessage.Mention mention ) { - final var address = m.resolveSignalServiceAddress(mention.getUuid()); + final var address = m.resolveSignalServiceAddress(new SignalServiceAddress(mention.getUuid())); writer.println("- {}: {} (length: {})", formatContact(address), mention.getStart(), mention.getLength()); } diff --git a/src/main/java/org/asamk/signal/commands/BlockCommand.java b/src/main/java/org/asamk/signal/commands/BlockCommand.java index 5394022e..516224f5 100644 --- a/src/main/java/org/asamk/signal/commands/BlockCommand.java +++ b/src/main/java/org/asamk/signal/commands/BlockCommand.java @@ -37,7 +37,7 @@ public class BlockCommand implements JsonRpcLocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { final var contacts = ns.getList("recipient"); - for (var contact : CommandUtil.getSingleRecipientIdentifiers(contacts, m.getUsername())) { + for (var contact : CommandUtil.getSingleRecipientIdentifiers(contacts, m.getSelfNumber())) { try { m.setContactBlocked(contact, true); } catch (NotMasterDeviceException e) { diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 5d637eee..1d6dd26d 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -39,6 +39,7 @@ public class Commands { addCommand(new UnblockCommand()); addCommand(new UnregisterCommand()); addCommand(new UpdateAccountCommand()); + addCommand(new UpdateConfigurationCommand()); addCommand(new UpdateContactCommand()); addCommand(new UpdateGroupCommand()); addCommand(new UpdateProfileCommand()); diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 4a322b99..02063b87 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -54,10 +54,10 @@ public class DaemonCommand implements MultiLocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - boolean ignoreAttachments = ns.getBoolean("ignore-attachments"); + boolean ignoreAttachments = Boolean.TRUE.equals(ns.getBoolean("ignore-attachments")); DBusConnection.DBusBusType busType; - if (ns.getBoolean("system")) { + if (Boolean.TRUE.equals(ns.getBoolean("system"))) { busType = DBusConnection.DBusBusType.SYSTEM; } else { busType = DBusConnection.DBusBusType.SESSION; @@ -83,10 +83,10 @@ public class DaemonCommand implements MultiLocalCommand { public void handleCommand( final Namespace ns, final List managers, final SignalCreator c, final OutputWriter outputWriter ) throws CommandException { - boolean ignoreAttachments = ns.getBoolean("ignore-attachments"); + boolean ignoreAttachments = Boolean.TRUE.equals(ns.getBoolean("ignore-attachments")); DBusConnection.DBusBusType busType; - if (ns.getBoolean("system")) { + if (Boolean.TRUE.equals(ns.getBoolean("system"))) { busType = DBusConnection.DBusBusType.SYSTEM; } else { busType = DBusConnection.DBusBusType.SESSION; @@ -95,7 +95,7 @@ public class DaemonCommand implements MultiLocalCommand { try (var conn = DBusConnection.getConnection(busType)) { final var signalControl = new DbusSignalControlImpl(c, m -> { try { - final var objectPath = DbusConfig.getObjectPath(m.getUsername()); + final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber()); return run(conn, objectPath, m, outputWriter, ignoreAttachments); } catch (DBusException e) { logger.error("Failed to export object", e); @@ -120,7 +120,10 @@ public class DaemonCommand implements MultiLocalCommand { private Thread run( DBusConnection conn, String objectPath, Manager m, OutputWriter outputWriter, boolean ignoreAttachments ) throws DBusException { - conn.exportObject(new DbusSignalImpl(m, objectPath)); + final var signal = new DbusSignalImpl(m, conn, objectPath); + conn.exportObject(signal); + final var initThread = new Thread(signal::initObjects); + initThread.start(); logger.info("Exported dbus object: " + objectPath); @@ -136,6 +139,11 @@ public class DaemonCommand implements MultiLocalCommand { logger.warn("Receiving messages failed, retrying", e); } } + try { + initThread.join(); + } catch (InterruptedException ignored) { + } + signal.close(); }); thread.start(); diff --git a/src/main/java/org/asamk/signal/commands/DbusCommand.java b/src/main/java/org/asamk/signal/commands/DbusCommand.java deleted file mode 100644 index 9f676a39..00000000 --- a/src/main/java/org/asamk/signal/commands/DbusCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.asamk.signal.commands; - -import net.sourceforge.argparse4j.inf.Namespace; - -import org.asamk.Signal; -import org.asamk.signal.OutputWriter; -import org.asamk.signal.commands.exceptions.CommandException; -import org.asamk.signal.dbus.DbusSignalImpl; -import org.asamk.signal.manager.Manager; - -public interface DbusCommand extends LocalCommand { - - void handleCommand(Namespace ns, Signal signal, OutputWriter outputWriter) throws CommandException; - - default void handleCommand( - final Namespace ns, final Manager m, final OutputWriter outputWriter - ) throws CommandException { - handleCommand(ns, new DbusSignalImpl(m, null), outputWriter); - } -} diff --git a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java index f5585881..1e06ea9c 100644 --- a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java @@ -57,14 +57,14 @@ public class JoinGroupCommand implements JsonRpcLocalCommand { var newGroupId = results.first(); if (outputWriter instanceof JsonWriter) { final var writer = (JsonWriter) outputWriter; - if (!m.getGroup(newGroupId).isMember(m.getSelfRecipientId())) { + if (!m.getGroup(newGroupId).isMember()) { writer.write(Map.of("groupId", newGroupId.toBase64(), "onlyRequested", true)); } else { writer.write(Map.of("groupId", newGroupId.toBase64())); } } else { final var writer = (PlainTextWriter) outputWriter; - if (!m.getGroup(newGroupId).isMember(m.getSelfRecipientId())) { + if (!m.getGroup(newGroupId).isMember()) { writer.println("Requested to join group \"{}\"", newGroupId.toBase64()); } else { writer.println("Joined group \"{}\"", newGroupId.toBase64()); diff --git a/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java b/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java index d0e4dfec..9af67322 100644 --- a/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java +++ b/src/main/java/org/asamk/signal/commands/JsonRpcDispatcherCommand.java @@ -65,7 +65,7 @@ public class JsonRpcDispatcherCommand implements LocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - final boolean ignoreAttachments = ns.getBoolean("ignore-attachments"); + final boolean ignoreAttachments = Boolean.TRUE.equals(ns.getBoolean("ignore-attachments")); final var objectMapper = Util.createJsonObjectMapper(); final var jsonRpcSender = new JsonRpcSender((JsonWriter) outputWriter); diff --git a/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java b/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java index 24b45ee8..5b926732 100644 --- a/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java +++ b/src/main/java/org/asamk/signal/commands/JsonRpcLocalCommand.java @@ -64,14 +64,5 @@ public interface JsonRpcLocalCommand extends JsonRpcCommand> return super.getList(dest + "s"); } - - @Override - public Boolean getBoolean(String dest) { - Boolean maybeGotten = this.get(dest); - if (maybeGotten == null) { - maybeGotten = false; - } - return maybeGotten; - } } } diff --git a/src/main/java/org/asamk/signal/commands/LinkCommand.java b/src/main/java/org/asamk/signal/commands/LinkCommand.java index fbc03300..1d697299 100644 --- a/src/main/java/org/asamk/signal/commands/LinkCommand.java +++ b/src/main/java/org/asamk/signal/commands/LinkCommand.java @@ -44,7 +44,7 @@ public class LinkCommand implements ProvisioningCommand { try { writer.println("{}", m.getDeviceLinkUri()); try (var manager = m.finishDeviceLink(deviceName)) { - writer.println("Associated with: {}", manager.getUsername()); + writer.println("Associated with: {}", manager.getSelfNumber()); } } catch (TimeoutException e) { throw new UserErrorException("Link request timed out, please try again."); @@ -52,7 +52,7 @@ public class LinkCommand implements ProvisioningCommand { throw new IOErrorException("Link request error: " + e.getMessage(), e); } catch (UserAlreadyExists e) { throw new UserErrorException("The user " - + e.getUsername() + + e.getNumber() + " already exists\nDelete \"" + e.getFileName() + "\" before trying again."); diff --git a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java index 5e609a48..b6dfc3ce 100644 --- a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java @@ -8,10 +8,9 @@ import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; import org.asamk.signal.manager.Manager; +import java.util.UUID; import java.util.stream.Collectors; -import static org.asamk.signal.util.Util.getLegacyIdentifier; - public class ListContactsCommand implements JsonRpcLocalCommand { @Override @@ -33,7 +32,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand { for (var c : contacts) { final var contact = c.second(); writer.println("Number: {} Name: {} Blocked: {} Message expiration: {}", - getLegacyIdentifier(m.resolveSignalServiceAddress(c.first())), + c.first().getLegacyIdentifier(), contact.getName(), contact.isBlocked(), contact.getMessageExpirationTime() == 0 @@ -43,10 +42,10 @@ public class ListContactsCommand implements JsonRpcLocalCommand { } else { final var writer = (JsonWriter) outputWriter; final var jsonContacts = contacts.stream().map(contactPair -> { - final var address = m.resolveSignalServiceAddress(contactPair.first()); + final var address = contactPair.first(); final var contact = contactPair.second(); - return new JsonContact(address.getNumber().orNull(), - address.getUuid().toString(), + return new JsonContact(address.getNumber().orElse(null), + address.getUuid().map(UUID::toString).orElse(null), contact.getName(), contact.isBlocked(), contact.getMessageExpirationTime()); diff --git a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java index ad0d3531..1de5b842 100644 --- a/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListDevicesCommand.java @@ -46,7 +46,7 @@ public class ListDevicesCommand implements JsonRpcLocalCommand { if (outputWriter instanceof PlainTextWriter) { final var writer = (PlainTextWriter) outputWriter; for (var d : devices) { - writer.println("- Device {}{}:", d.getId(), (d.getId() == m.getDeviceId() ? " (this device)" : "")); + writer.println("- Device {}{}:", d.getId(), (d.isThisDevice() ? " (this device)" : "")); writer.indent(w -> { w.println("Name: {}", d.getName()); w.println("Created: {}", DateUtils.formatTimestamp(d.getCreated())); diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index b53577be..fd8c4b92 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -9,13 +9,13 @@ import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.storage.groups.GroupInfo; -import org.asamk.signal.manager.storage.recipients.RecipientId; -import org.asamk.signal.util.Util; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; public class ListGroupsCommand implements JsonRpcLocalCommand { @@ -35,44 +35,41 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { .help("List the members and group invite links of each group. If output=json, then this is always set"); } - private static Set resolveMembers(Manager m, Set addresses) { - return addresses.stream() - .map(m::resolveSignalServiceAddress) - .map(Util::getLegacyIdentifier) - .collect(Collectors.toSet()); + private static Set resolveMembers(Set addresses) { + return addresses.stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toSet()); } - private static Set resolveJsonMembers(Manager m, Set addresses) { + private static Set resolveJsonMembers(Set addresses) { return addresses.stream() - .map(m::resolveSignalServiceAddress) - .map(address -> new JsonGroupMember(address.getNumber().orNull(), address.getUuid().toString())) + .map(address -> new JsonGroupMember(address.getNumber().orElse(null), + address.getUuid().map(UUID::toString).orElse(null))) .collect(Collectors.toSet()); } private static void printGroupPlainText( - PlainTextWriter writer, Manager m, GroupInfo group, boolean detailed + PlainTextWriter writer, Group group, boolean detailed ) { if (detailed) { - final var groupInviteLink = group.getGroupInviteLink(); + final var groupInviteLink = group.getGroupInviteLinkUrl(); writer.println( "Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Admins: {} Message expiration: {} Link: {}", group.getGroupId().toBase64(), group.getTitle(), group.getDescription(), - group.isMember(m.getSelfRecipientId()), + group.isMember(), group.isBlocked(), - resolveMembers(m, group.getMembers()), - resolveMembers(m, group.getPendingMembers()), - resolveMembers(m, group.getRequestingMembers()), - resolveMembers(m, group.getAdminMembers()), + resolveMembers(group.getMembers()), + resolveMembers(group.getPendingMembers()), + resolveMembers(group.getRequestingMembers()), + resolveMembers(group.getAdminMembers()), group.getMessageExpirationTime() == 0 ? "disabled" : group.getMessageExpirationTime() + "s", groupInviteLink == null ? '-' : groupInviteLink.getUrl()); } else { writer.println("Id: {} Name: {} Active: {} Blocked: {}", group.getGroupId().toBase64(), group.getTitle(), - group.isMember(m.getSelfRecipientId()), + group.isMember(), group.isBlocked()); } } @@ -87,27 +84,27 @@ public class ListGroupsCommand implements JsonRpcLocalCommand { final var jsonWriter = (JsonWriter) outputWriter; var jsonGroups = groups.stream().map(group -> { - final var groupInviteLink = group.getGroupInviteLink(); + final var groupInviteLink = group.getGroupInviteLinkUrl(); return new JsonGroup(group.getGroupId().toBase64(), group.getTitle(), group.getDescription(), - group.isMember(m.getSelfRecipientId()), + group.isMember(), group.isBlocked(), group.getMessageExpirationTime(), - resolveJsonMembers(m, group.getMembers()), - resolveJsonMembers(m, group.getPendingMembers()), - resolveJsonMembers(m, group.getRequestingMembers()), - resolveJsonMembers(m, group.getAdminMembers()), + resolveJsonMembers(group.getMembers()), + resolveJsonMembers(group.getPendingMembers()), + resolveJsonMembers(group.getRequestingMembers()), + resolveJsonMembers(group.getAdminMembers()), groupInviteLink == null ? null : groupInviteLink.getUrl()); }).collect(Collectors.toList()); jsonWriter.write(jsonGroups); } else { final var writer = (PlainTextWriter) outputWriter; - boolean detailed = ns.getBoolean("detailed"); + boolean detailed = Boolean.TRUE.equals(ns.getBoolean("detailed")); for (var group : groups) { - printGroupPlainText(writer, m, group, detailed); + printGroupPlainText(writer, group, detailed); } } } diff --git a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java index 02cd1d9f..ed2942a5 100644 --- a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java @@ -8,7 +8,7 @@ import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.storage.identities.IdentityInfo; +import org.asamk.signal.manager.api.Identity; import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.Hex; import org.asamk.signal.util.Util; @@ -29,9 +29,9 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand { return "listIdentities"; } - private static void printIdentityFingerprint(PlainTextWriter writer, Manager m, IdentityInfo theirId) { - final SignalServiceAddress address = m.resolveSignalServiceAddress(theirId.getRecipientId()); - var digits = Util.formatSafetyNumber(m.computeSafetyNumber(address, theirId.getIdentityKey())); + private static void printIdentityFingerprint(PlainTextWriter writer, Manager m, Identity theirId) { + final SignalServiceAddress address = theirId.getRecipient().toSignalServiceAddress(); + var digits = Util.formatSafetyNumber(theirId.getSafetyNumber()); writer.println("{}: {} Added: {} Fingerprint: {} Safety Number: {}", address.getNumber().orNull(), theirId.getTrustLevel(), @@ -52,11 +52,11 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand { ) throws CommandException { var number = ns.getString("number"); - List identities; + List identities; if (number == null) { identities = m.getIdentities(); } else { - identities = m.getIdentities(CommandUtil.getSingleRecipientIdentifier(number, m.getUsername())); + identities = m.getIdentities(CommandUtil.getSingleRecipientIdentifier(number, m.getSelfNumber())); } if (outputWriter instanceof PlainTextWriter) { @@ -67,9 +67,9 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand { } else { final var writer = (JsonWriter) outputWriter; final var jsonIdentities = identities.stream().map(id -> { - final var address = m.resolveSignalServiceAddress(id.getRecipientId()); - var safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(address, id.getIdentityKey())); - var scannableSafetyNumber = m.computeSafetyNumberForScanning(address, id.getIdentityKey()); + final var address = id.getRecipient().toSignalServiceAddress(); + var safetyNumber = Util.formatSafetyNumber(id.getSafetyNumber()); + var scannableSafetyNumber = id.getScannableSafetyNumber(); return new JsonIdentity(address.getNumber().orNull(), address.getUuid().toString(), Hex.toString(id.getFingerprint()), diff --git a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java index 67a6596b..1d6611b5 100644 --- a/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/QuitGroupCommand.java @@ -50,7 +50,7 @@ public class QuitGroupCommand implements JsonRpcLocalCommand { ) throws CommandException { final var groupId = CommandUtil.getGroupId(ns.getString("group-id")); - var groupAdmins = CommandUtil.getSingleRecipientIdentifiers(ns.getList("admin"), m.getUsername()); + var groupAdmins = CommandUtil.getSingleRecipientIdentifiers(ns.getList("admin"), m.getSelfNumber()); try { try { @@ -61,7 +61,7 @@ public class QuitGroupCommand implements JsonRpcLocalCommand { } catch (NotAGroupMemberException e) { logger.info("User is not a group member"); } - if (ns.getBoolean("delete")) { + if (Boolean.TRUE.equals(ns.getBoolean("delete"))) { logger.debug("Deleting group {}", groupId); m.deleteGroup(groupId); } diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index 92b2a4c8..75c175b2 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -147,7 +147,7 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { returnOnTimeout = false; timeout = 3600; } - boolean ignoreAttachments = ns.getBoolean("ignore-attachments"); + boolean ignoreAttachments = Boolean.TRUE.equals(ns.getBoolean("ignore-attachments")); try { final var handler = outputWriter instanceof JsonWriter ? new JsonReceiveMessageHandler(m, (JsonWriter) outputWriter) : new ReceiveMessageHandler(m, (PlainTextWriter) outputWriter); diff --git a/src/main/java/org/asamk/signal/commands/RegisterCommand.java b/src/main/java/org/asamk/signal/commands/RegisterCommand.java index 96530889..af6c06ad 100644 --- a/src/main/java/org/asamk/signal/commands/RegisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/RegisterCommand.java @@ -31,7 +31,7 @@ public class RegisterCommand implements RegistrationCommand { @Override public void handleCommand(final Namespace ns, final RegistrationManager m) throws CommandException { - final boolean voiceVerification = ns.getBoolean("voice"); + final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice")); final var captchaString = ns.getString("captcha"); final var captcha = captchaString == null ? null : captchaString.replace("signalcaptcha://", ""); diff --git a/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java index 7d7067c4..c9eab95c 100644 --- a/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemoteDeleteCommand.java @@ -4,7 +4,6 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.Signal; import org.asamk.signal.JsonWriter; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; @@ -17,13 +16,11 @@ import org.asamk.signal.manager.groups.GroupSendingNotAllowedException; import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.ErrorUtils; -import org.freedesktop.dbus.errors.UnknownObject; -import org.freedesktop.dbus.exceptions.DBusExecutionException; import java.io.IOException; import java.util.Map; -public class RemoteDeleteCommand implements DbusCommand, JsonRpcLocalCommand { +public class RemoteDeleteCommand implements JsonRpcLocalCommand { @Override public String getName() { @@ -46,7 +43,7 @@ public class RemoteDeleteCommand implements DbusCommand, JsonRpcLocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - final var isNoteToSelf = ns.getBoolean("note-to-self"); + final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self")); final var recipientStrings = ns.getList("recipient"); final var groupIdStrings = ns.getList("group-id"); @@ -69,47 +66,6 @@ public class RemoteDeleteCommand implements DbusCommand, JsonRpcLocalCommand { } } - @Override - public void handleCommand( - final Namespace ns, final Signal signal, final OutputWriter outputWriter - ) throws CommandException { - final var recipients = ns.getList("recipient"); - final var groupIdStrings = ns.getList("group-id"); - - final var noRecipients = recipients == null || recipients.isEmpty(); - final var noGroups = groupIdStrings == null || groupIdStrings.isEmpty(); - if (noRecipients && noGroups) { - throw new UserErrorException("No recipients given"); - } - if (!noRecipients && !noGroups) { - throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); - } - - final long targetTimestamp = ns.getLong("target-timestamp"); - - try { - long timestamp = 0; - if (!noGroups) { - final var groupIds = CommandUtil.getGroupIds(groupIdStrings); - for (final var groupId : groupIds) { - timestamp = signal.sendGroupRemoteDeleteMessage(targetTimestamp, groupId.serialize()); - } - } else { - timestamp = signal.sendRemoteDeleteMessage(targetTimestamp, recipients); - } - outputResult(outputWriter, timestamp); - } catch (UnknownObject e) { - throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); - } catch (Signal.Error.InvalidNumber e) { - throw new UserErrorException("Invalid number: " + e.getMessage()); - } catch (Signal.Error.GroupNotFound e) { - throw new UserErrorException("Failed to send to group: " + e.getMessage()); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")", e); - } - } - private void outputResult(final OutputWriter outputWriter, final long timestamp) { if (outputWriter instanceof PlainTextWriter) { final var writer = (PlainTextWriter) outputWriter; diff --git a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java index d67cc5ea..4fcad79d 100644 --- a/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java +++ b/src/main/java/org/asamk/signal/commands/RemoveDeviceCommand.java @@ -21,7 +21,7 @@ public class RemoveDeviceCommand implements JsonRpcLocalCommand { public void attachToSubparser(final Subparser subparser) { subparser.help("Remove a linked device."); subparser.addArgument("-d", "--device-id", "--deviceId") - .type(int.class) + .type(long.class) .required(true) .help("Specify the device you want to remove. Use listDevices to see the deviceIds."); } @@ -31,7 +31,7 @@ public class RemoveDeviceCommand implements JsonRpcLocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { try { - int deviceId = ns.getInt("device-id"); + final var deviceId = ns.getLong("device-id"); m.removeLinkedDevices(deviceId); } catch (IOException e) { throw new IOErrorException("Error while removing device: " + e.getMessage(), e); diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index 1973b1a1..dba7689f 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -4,13 +4,11 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.Signal; import org.asamk.signal.JsonWriter; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.UnexpectedErrorException; -import org.asamk.signal.commands.exceptions.UntrustedKeyErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.AttachmentInvalidException; import org.asamk.signal.manager.Manager; @@ -22,8 +20,6 @@ import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.ErrorUtils; import org.asamk.signal.util.IOUtils; -import org.freedesktop.dbus.errors.UnknownObject; -import org.freedesktop.dbus.exceptions.DBusExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,7 +29,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -public class SendCommand implements DbusCommand, JsonRpcLocalCommand { +public class SendCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(SendCommand.class); @@ -62,7 +58,7 @@ public class SendCommand implements DbusCommand, JsonRpcLocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - final var isNoteToSelf = ns.getBoolean("note-to-self"); + final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self")); final var recipientStrings = ns.getList("recipient"); final var groupIdStrings = ns.getList("group-id"); @@ -71,7 +67,7 @@ public class SendCommand implements DbusCommand, JsonRpcLocalCommand { recipientStrings, groupIdStrings); - final var isEndSession = ns.getBoolean("end-session"); + final var isEndSession = Boolean.TRUE.equals(ns.getBoolean("end-session")); if (isEndSession) { final var singleRecipients = recipientIdentifiers.stream() .filter(r -> r instanceof RecipientIdentifier.Single) @@ -116,97 +112,6 @@ public class SendCommand implements DbusCommand, JsonRpcLocalCommand { } } - @Override - public void handleCommand( - final Namespace ns, final Signal signal, final OutputWriter outputWriter - ) throws CommandException { - final var recipients = ns.getList("recipient"); - final var isEndSession = ns.getBoolean("end-session"); - final var groupIdStrings = ns.getList("group-id"); - final var isNoteToSelf = ns.getBoolean("note-to-self"); - - final var noRecipients = recipients == null || recipients.isEmpty(); - final var noGroups = groupIdStrings == null || groupIdStrings.isEmpty(); - if ((noRecipients && isEndSession) || (noRecipients && noGroups && !isNoteToSelf)) { - throw new UserErrorException("No recipients given"); - } - if (!noRecipients && !noGroups) { - throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); - } - if (!noRecipients && isNoteToSelf) { - throw new UserErrorException( - "You cannot specify recipients by phone number and note to self at the same time"); - } - - if (isEndSession) { - try { - signal.sendEndSessionMessage(recipients); - return; - } catch (Signal.Error.UntrustedIdentity e) { - throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")"); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")", e); - } - } - - var messageText = ns.getString("message"); - if (messageText == null) { - try { - messageText = IOUtils.readAll(System.in, Charset.defaultCharset()); - } catch (IOException e) { - throw new UserErrorException("Failed to read message from stdin: " + e.getMessage()); - } - } - - List attachments = ns.getList("attachment"); - if (attachments == null) { - attachments = List.of(); - } - - if (!noGroups) { - final var groupIds = CommandUtil.getGroupIds(groupIdStrings); - - try { - long timestamp = 0; - for (final var groupId : groupIds) { - timestamp = signal.sendGroupMessage(messageText, attachments, groupId.serialize()); - } - outputResult(outputWriter, timestamp); - return; - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send group message: " + e.getMessage(), e); - } - } - - if (isNoteToSelf) { - try { - var timestamp = signal.sendNoteToSelfMessage(messageText, attachments); - outputResult(outputWriter, timestamp); - return; - } catch (Signal.Error.UntrustedIdentity e) { - throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")"); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send note to self message: " + e.getMessage(), e); - } - } - - try { - var timestamp = signal.sendMessage(messageText, attachments, recipients); - outputResult(outputWriter, timestamp); - } catch (UnknownObject e) { - throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); - } catch (Signal.Error.UntrustedIdentity e) { - throw new UntrustedKeyErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")"); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")", e); - } - } - private void outputResult(final OutputWriter outputWriter, final long timestamp) { if (outputWriter instanceof PlainTextWriter) { final var writer = (PlainTextWriter) outputWriter; diff --git a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java index 338e70ac..857f603d 100644 --- a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java @@ -4,7 +4,6 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.Signal; import org.asamk.signal.JsonWriter; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; @@ -17,13 +16,11 @@ import org.asamk.signal.manager.groups.GroupSendingNotAllowedException; import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.ErrorUtils; -import org.freedesktop.dbus.errors.UnknownObject; -import org.freedesktop.dbus.exceptions.DBusExecutionException; import java.io.IOException; import java.util.Map; -public class SendReactionCommand implements DbusCommand, JsonRpcLocalCommand { +public class SendReactionCommand implements JsonRpcLocalCommand { @Override public String getName() { @@ -55,7 +52,7 @@ public class SendReactionCommand implements DbusCommand, JsonRpcLocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - final var isNoteToSelf = ns.getBoolean("note-to-self"); + final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self")); final var recipientStrings = ns.getList("recipient"); final var groupIdStrings = ns.getList("group-id"); @@ -65,14 +62,14 @@ public class SendReactionCommand implements DbusCommand, JsonRpcLocalCommand { groupIdStrings); final var emoji = ns.getString("emoji"); - final var isRemove = ns.getBoolean("remove"); + final var isRemove = Boolean.TRUE.equals(ns.getBoolean("remove")); final var targetAuthor = ns.getString("target-author"); final var targetTimestamp = ns.getLong("target-timestamp"); try { final var results = m.sendMessageReaction(emoji, isRemove, - CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getUsername()), + CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber()), targetTimestamp, recipientIdentifiers); outputResult(outputWriter, results.getTimestamp()); @@ -85,54 +82,6 @@ public class SendReactionCommand implements DbusCommand, JsonRpcLocalCommand { } } - @Override - public void handleCommand( - final Namespace ns, final Signal signal, final OutputWriter outputWriter - ) throws CommandException { - final var recipients = ns.getList("recipient"); - final var groupIdStrings = ns.getList("group-id"); - - final var noRecipients = recipients == null || recipients.isEmpty(); - final var noGroups = groupIdStrings == null || groupIdStrings.isEmpty(); - if (noRecipients && noGroups) { - throw new UserErrorException("No recipients given"); - } - if (!noRecipients && !noGroups) { - throw new UserErrorException("You cannot specify recipients by phone number and groups at the same time"); - } - - final var emoji = ns.getString("emoji"); - final var isRemove = ns.getBoolean("remove"); - final var targetAuthor = ns.getString("target-author"); - final var targetTimestamp = ns.getLong("target-timestamp"); - - try { - long timestamp = 0; - if (!noGroups) { - final var groupIds = CommandUtil.getGroupIds(groupIdStrings); - for (final var groupId : groupIds) { - timestamp = signal.sendGroupMessageReaction(emoji, - isRemove, - targetAuthor, - targetTimestamp, - groupId.serialize()); - } - } else { - timestamp = signal.sendMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, recipients); - } - outputResult(outputWriter, timestamp); - } catch (UnknownObject e) { - throw new UserErrorException("Failed to find dbus object, maybe missing the -u flag: " + e.getMessage()); - } catch (Signal.Error.InvalidNumber e) { - throw new UserErrorException("Invalid number: " + e.getMessage()); - } catch (Signal.Error.GroupNotFound e) { - throw new UserErrorException("Failed to send to group: " + e.getMessage()); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")", e); - } - } - private void outputResult(final OutputWriter outputWriter, final long timestamp) { if (outputWriter instanceof PlainTextWriter) { final var writer = (PlainTextWriter) outputWriter; diff --git a/src/main/java/org/asamk/signal/commands/SendReceiptCommand.java b/src/main/java/org/asamk/signal/commands/SendReceiptCommand.java index 0d5772ec..5dd29682 100644 --- a/src/main/java/org/asamk/signal/commands/SendReceiptCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReceiptCommand.java @@ -37,7 +37,7 @@ public class SendReceiptCommand implements JsonRpcLocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { final var recipientString = ns.getString("recipient"); - final var recipient = CommandUtil.getSingleRecipientIdentifier(recipientString, m.getUsername()); + final var recipient = CommandUtil.getSingleRecipientIdentifier(recipientString, m.getSelfNumber()); final var targetTimestamps = ns.getList("target-timestamp"); final var type = ns.getString("type"); diff --git a/src/main/java/org/asamk/signal/commands/SendTypingCommand.java b/src/main/java/org/asamk/signal/commands/SendTypingCommand.java index 3a965e47..ba062b70 100644 --- a/src/main/java/org/asamk/signal/commands/SendTypingCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendTypingCommand.java @@ -41,11 +41,11 @@ public class SendTypingCommand implements JsonRpcLocalCommand { ) throws CommandException { final var recipientStrings = ns.getList("recipient"); final var groupIdStrings = ns.getList("group-id"); - final var action = ns.getBoolean("stop") ? TypingAction.STOP : TypingAction.START; + final var action = Boolean.TRUE.equals(ns.getBoolean("stop")) ? TypingAction.STOP : TypingAction.START; final var recipientIdentifiers = new HashSet(); if (recipientStrings != null) { - final var localNumber = m.getUsername(); + final var localNumber = m.getSelfNumber(); recipientIdentifiers.addAll(CommandUtil.getSingleRecipientIdentifiers(recipientStrings, localNumber)); } if (groupIdStrings != null) { diff --git a/src/main/java/org/asamk/signal/commands/TrustCommand.java b/src/main/java/org/asamk/signal/commands/TrustCommand.java index aedc2c3e..77fcc08a 100644 --- a/src/main/java/org/asamk/signal/commands/TrustCommand.java +++ b/src/main/java/org/asamk/signal/commands/TrustCommand.java @@ -38,8 +38,8 @@ public class TrustCommand implements JsonRpcLocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { var recipentString = ns.getString("recipient"); - var recipient = CommandUtil.getSingleRecipientIdentifier(recipentString, m.getUsername()); - if (ns.getBoolean("trust-all-known-keys")) { + var recipient = CommandUtil.getSingleRecipientIdentifier(recipentString, m.getSelfNumber()); + if (Boolean.TRUE.equals(ns.getBoolean("trust-all-known-keys"))) { boolean res = m.trustIdentityAllKeys(recipient); if (!res) { throw new UserErrorException("Failed to set the trust for this number, make sure the number is correct."); diff --git a/src/main/java/org/asamk/signal/commands/UnblockCommand.java b/src/main/java/org/asamk/signal/commands/UnblockCommand.java index 812065bc..7cf209fa 100644 --- a/src/main/java/org/asamk/signal/commands/UnblockCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnblockCommand.java @@ -36,7 +36,8 @@ public class UnblockCommand implements JsonRpcLocalCommand { public void handleCommand( final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { - for (var contactNumber : CommandUtil.getSingleRecipientIdentifiers(ns.getList("recipient"), m.getUsername())) { + for (var contactNumber : CommandUtil.getSingleRecipientIdentifiers(ns.getList("recipient"), + m.getSelfNumber())) { try { m.setContactBlocked(contactNumber, false); } catch (NotMasterDeviceException e) { diff --git a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java index 60260046..68a20375 100644 --- a/src/main/java/org/asamk/signal/commands/UnregisterCommand.java +++ b/src/main/java/org/asamk/signal/commands/UnregisterCommand.java @@ -31,7 +31,7 @@ public class UnregisterCommand implements LocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { try { - if (ns.getBoolean("delete-account")) { + if (Boolean.TRUE.equals(ns.getBoolean("delete-account"))) { m.deleteAccount(); } else { m.unregister(); diff --git a/src/main/java/org/asamk/signal/commands/UpdateConfigurationCommand.java b/src/main/java/org/asamk/signal/commands/UpdateConfigurationCommand.java new file mode 100644 index 00000000..9ca126d0 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/UpdateConfigurationCommand.java @@ -0,0 +1,55 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.OutputWriter; +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.NotMasterDeviceException; + +import java.io.IOException; + +public class UpdateConfigurationCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "updateConfiguration"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Update signal configs and sync them to linked devices."); + subparser.addArgument("--read-receipts") + .type(Boolean.class) + .help("Indicates if Signal should send read receipts."); + subparser.addArgument("--unidentified-delivery-indicators") + .type(Boolean.class) + .help("Indicates if Signal should show unidentified delivery indicators."); + subparser.addArgument("--typing-indicators") + .type(Boolean.class) + .help("Indicates if Signal should send/show typing indicators."); + subparser.addArgument("--link-previews") + .type(Boolean.class) + .help("Indicates if Signal should generate link previews."); + } + + @Override + public void handleCommand( + final Namespace ns, final Manager m, final OutputWriter outputWriter + ) throws CommandException { + final var readReceipts = ns.getBoolean("read-receipts"); + final var unidentifiedDeliveryIndicators = ns.getBoolean("unidentified-delivery-indicators"); + final var typingIndicators = ns.getBoolean("typing-indicators"); + final var linkPreviews = ns.getBoolean("link-previews"); + try { + m.updateConfiguration(readReceipts, unidentifiedDeliveryIndicators, typingIndicators, linkPreviews); + } catch (IOException e) { + throw new IOErrorException("UpdateAccount error: " + e.getMessage(), e); + } catch (NotMasterDeviceException e) { + throw new UserErrorException("This command doesn't work on linked devices."); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java index 6c2916eb..46641668 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateContactCommand.java @@ -33,7 +33,7 @@ public class UpdateContactCommand implements JsonRpcLocalCommand { final Namespace ns, final Manager m, final OutputWriter outputWriter ) throws CommandException { var recipientString = ns.getString("recipient"); - var recipient = CommandUtil.getSingleRecipientIdentifier(recipientString, m.getUsername()); + var recipient = CommandUtil.getSingleRecipientIdentifier(recipientString, m.getSelfNumber()); try { var expiration = ns.getInt("expiration"); diff --git a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java index b0269894..68bce2d2 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java @@ -4,7 +4,6 @@ import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; -import org.asamk.Signal; import org.asamk.signal.JsonWriter; import org.asamk.signal.OutputWriter; import org.asamk.signal.PlainTextWriter; @@ -21,17 +20,14 @@ import org.asamk.signal.manager.groups.GroupSendingNotAllowedException; import org.asamk.signal.manager.groups.NotAGroupMemberException; import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.ErrorUtils; -import org.freedesktop.dbus.exceptions.DBusExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; -public class UpdateGroupCommand implements DbusCommand, JsonRpcLocalCommand { +public class UpdateGroupCommand implements JsonRpcLocalCommand { private final static Logger logger = LoggerFactory.getLogger(UpdateGroupCommand.class); @@ -116,7 +112,7 @@ public class UpdateGroupCommand implements DbusCommand, JsonRpcLocalCommand { final var groupIdString = ns.getString("group-id"); var groupId = CommandUtil.getGroupId(groupIdString); - final var localNumber = m.getUsername(); + final var localNumber = m.getSelfNumber(); var groupName = ns.getString("name"); var groupDescription = ns.getString("description"); @@ -125,7 +121,7 @@ public class UpdateGroupCommand implements DbusCommand, JsonRpcLocalCommand { var groupAdmins = CommandUtil.getSingleRecipientIdentifiers(ns.getList("admin"), localNumber); var groupRemoveAdmins = CommandUtil.getSingleRecipientIdentifiers(ns.getList("remove-admin"), localNumber); var groupAvatar = ns.getString("avatar"); - var groupResetLink = ns.getBoolean("reset-link"); + var groupResetLink = Boolean.TRUE.equals(ns.getBoolean("reset-link")); var groupLinkState = getGroupLinkState(ns.getString("link")); var groupExpiration = ns.getInt("expiration"); var groupAddMemberPermission = getGroupPermission(ns.getString("set-permission-add-member")); @@ -179,43 +175,6 @@ public class UpdateGroupCommand implements DbusCommand, JsonRpcLocalCommand { } } - @Override - public void handleCommand( - final Namespace ns, final Signal signal, final OutputWriter outputWriter - ) throws CommandException { - var groupId = CommandUtil.getGroupId(ns.getString("group-id")); - - var groupName = ns.getString("name"); - if (groupName == null) { - groupName = ""; - } - - List groupMembers = ns.getList("member"); - if (groupMembers == null) { - groupMembers = new ArrayList<>(); - } - - var groupAvatar = ns.getString("avatar"); - if (groupAvatar == null) { - groupAvatar = ""; - } - - try { - var newGroupId = signal.updateGroup(groupId == null ? new byte[0] : groupId.serialize(), - groupName, - groupMembers, - groupAvatar); - if (groupId == null) { - outputResult(outputWriter, null, GroupId.unknownVersion(newGroupId)); - } - } catch (Signal.Error.AttachmentInvalid e) { - throw new UserErrorException("Failed to add avatar attachment for group\": " + e.getMessage()); - } catch (DBusExecutionException e) { - throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass() - .getSimpleName() + ")", e); - } - } - private void outputResult(final OutputWriter outputWriter, final Long timestamp, final GroupId groupId) { if (outputWriter instanceof PlainTextWriter) { final var writer = (PlainTextWriter) outputWriter; diff --git a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java index f6dcb30e..9890a597 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java @@ -42,7 +42,7 @@ public class UpdateProfileCommand implements JsonRpcLocalCommand { var about = ns.getString("about"); var aboutEmoji = ns.getString("about-emoji"); var avatarPath = ns.getString("avatar"); - boolean removeAvatar = ns.getBoolean("remove-avatar"); + boolean removeAvatar = Boolean.TRUE.equals(ns.getBoolean("remove-avatar")); Optional avatarFile = removeAvatar ? Optional.absent() diff --git a/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java b/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java index 84e957cc..819ce495 100644 --- a/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java +++ b/src/main/java/org/asamk/signal/commands/exceptions/UserErrorException.java @@ -5,4 +5,8 @@ public final class UserErrorException extends CommandException { public UserErrorException(final String message) { super(message); } + + public UserErrorException(final String message, final Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/asamk/signal/dbus/DbusInterfacePropertiesHandler.java b/src/main/java/org/asamk/signal/dbus/DbusInterfacePropertiesHandler.java new file mode 100644 index 00000000..d3c2ca83 --- /dev/null +++ b/src/main/java/org/asamk/signal/dbus/DbusInterfacePropertiesHandler.java @@ -0,0 +1,46 @@ +package org.asamk.signal.dbus; + +import org.asamk.Signal; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class DbusInterfacePropertiesHandler { + + private final String interfaceName; + private final List> properties; + + public DbusInterfacePropertiesHandler( + final String interfaceName, final List> properties + ) { + this.interfaceName = interfaceName; + this.properties = properties; + } + + public String getInterfaceName() { + return interfaceName; + } + + @SuppressWarnings("unchecked") + private DbusProperty findProperty(String propertyName) { + final var property = properties.stream().filter(p -> p.getName().equals(propertyName)).findFirst(); + if (property.isEmpty()) { + throw new Signal.Error.Failure("Property not found"); + } + return (DbusProperty) property.get(); + } + + Consumer getSetter(String propertyName) { + return this.findProperty(propertyName).getSetter(); + } + + Supplier getGetter(String propertyName) { + return this.findProperty(propertyName).getGetter(); + } + + Collection> getProperties() { + return properties; + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java new file mode 100644 index 00000000..3124a5b0 --- /dev/null +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -0,0 +1,519 @@ +package org.asamk.signal.dbus; + +import org.asamk.Signal; +import org.asamk.signal.DbusConfig; +import org.asamk.signal.manager.AttachmentInvalidException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.NotMasterDeviceException; +import org.asamk.signal.manager.StickerPackInvalidException; +import org.asamk.signal.manager.UntrustedIdentityException; +import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.Message; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.groups.GroupId; +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.GroupSendingNotAllowedException; +import org.asamk.signal.manager.groups.LastGroupAdminException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.storage.recipients.Contact; +import org.asamk.signal.manager.storage.recipients.Profile; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; +import org.freedesktop.dbus.DBusPath; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.exceptions.DBusException; +import org.freedesktop.dbus.interfaces.DBusInterface; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * This class implements the Manager interface using the DBus Signal interface, where possible. + * It's used for the signal-cli dbus client mode (--dbus, --dbus-system) + */ +public class DbusManagerImpl implements Manager { + + private final Signal signal; + private final DBusConnection connection; + + public DbusManagerImpl(final Signal signal, DBusConnection connection) { + this.signal = signal; + this.connection = connection; + } + + @Override + public String getSelfNumber() { + return signal.getSelfNumber(); + } + + @Override + public void checkAccountState() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public Map> areUsersRegistered(final Set numbers) throws IOException { + final var numbersList = new ArrayList<>(numbers); + final var registered = signal.isRegistered(numbersList); + + final var result = new HashMap>(); + for (var i = 0; i < numbersList.size(); i++) { + result.put(numbersList.get(i), + new Pair<>(numbersList.get(i), registered.get(i) ? UuidUtil.UNKNOWN_UUID : null)); + } + return result; + } + + @Override + public void updateAccountAttributes(final String deviceName) throws IOException { + if (deviceName != null) { + final var devicePath = signal.getThisDevice(); + getRemoteObject(devicePath, Signal.Device.class).Set("org.asamk.Signal.Device", "Name", deviceName); + } + } + + @Override + public void updateConfiguration( + final Boolean readReceipts, + final Boolean unidentifiedDeliveryIndicators, + final Boolean typingIndicators, + final Boolean linkPreviews + ) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void setProfile( + final String givenName, + final String familyName, + final String about, + final String aboutEmoji, + final Optional avatar + ) throws IOException { + signal.updateProfile(emptyIfNull(givenName), + emptyIfNull(familyName), + emptyIfNull(about), + emptyIfNull(aboutEmoji), + avatar == null ? "" : avatar.transform(File::getPath).or(""), + avatar != null && !avatar.isPresent()); + } + + @Override + public void unregister() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteAccount() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void submitRateLimitRecaptchaChallenge(final String challenge, final String captcha) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public List getLinkedDevices() throws IOException { + final var thisDevice = signal.getThisDevice(); + return signal.listDevices().stream().map(devicePath -> { + final var device = getRemoteObject(devicePath, Signal.Device.class).GetAll("org.asamk.Signal.Device"); + return new Device((long) device.get("Id").getValue(), + (String) device.get("Name").getValue(), + (long) device.get("Created").getValue(), + (long) device.get("LastSeen").getValue(), + thisDevice.equals(devicePath)); + }).collect(Collectors.toList()); + } + + @Override + public void removeLinkedDevices(final long deviceId) throws IOException { + final var devicePath = signal.getDevice(deviceId); + getRemoteObject(devicePath, Signal.Device.class).removeDevice(); + } + + @Override + public void addDeviceLink(final URI linkUri) throws IOException, InvalidKeyException { + signal.addDevice(linkUri.toString()); + } + + @Override + public void setRegistrationLockPin(final Optional pin) throws IOException, UnauthenticatedResponseException { + if (pin.isPresent()) { + signal.setPin(pin.get()); + } else { + signal.removePin(); + } + } + + @Override + public Profile getRecipientProfile(final RecipientIdentifier.Single recipient) throws UnregisteredUserException { + throw new UnsupportedOperationException(); + } + + @Override + public List getGroups() { + final var groupIds = signal.getGroupIds(); + return groupIds.stream().map(id -> getGroup(GroupId.unknownVersion(id))).collect(Collectors.toList()); + } + + @Override + public SendGroupMessageResults quitGroup( + final GroupId groupId, final Set groupAdmins + ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException { + if (groupAdmins.size() > 0) { + throw new UnsupportedOperationException(); + } + signal.quitGroup(groupId.serialize()); + return new SendGroupMessageResults(0, List.of()); + } + + @Override + public void deleteGroup(final GroupId groupId) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public Pair createGroup( + final String name, final Set members, final File avatarFile + ) throws IOException, AttachmentInvalidException { + final var newGroupId = signal.updateGroup(new byte[0], + emptyIfNull(name), + members.stream().map(RecipientIdentifier.Single::getIdentifier).collect(Collectors.toList()), + avatarFile == null ? "" : avatarFile.getPath()); + return new Pair<>(GroupId.unknownVersion(newGroupId), new SendGroupMessageResults(0, List.of())); + } + + @Override + public SendGroupMessageResults 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, + final Boolean isAnnouncementGroup + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException { + signal.updateGroup(groupId.serialize(), + emptyIfNull(name), + members.stream().map(RecipientIdentifier.Single::getIdentifier).collect(Collectors.toList()), + avatarFile == null ? "" : avatarFile.getPath()); + return new SendGroupMessageResults(0, List.of()); + } + + @Override + public Pair joinGroup(final GroupInviteLinkUrl inviteLinkUrl) throws IOException, GroupLinkNotActiveException { + final var newGroupId = signal.joinGroup(inviteLinkUrl.getUrl()); + return new Pair<>(GroupId.unknownVersion(newGroupId), new SendGroupMessageResults(0, List.of())); + } + + @Override + public void sendTypingMessage( + final TypingAction action, final Set recipients + ) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + for (final var recipient : recipients) { + if (recipient instanceof RecipientIdentifier.Single) { + signal.sendTyping(((RecipientIdentifier.Single) recipient).getIdentifier(), + action == TypingAction.STOP); + } else if (recipient instanceof RecipientIdentifier.Group) { + throw new UnsupportedOperationException(); + } + } + } + + @Override + public void sendReadReceipt( + final RecipientIdentifier.Single sender, final List messageIds + ) throws IOException, UntrustedIdentityException { + signal.sendReadReceipt(sender.getIdentifier(), messageIds); + } + + @Override + public void sendViewedReceipt( + final RecipientIdentifier.Single sender, final List messageIds + ) throws IOException, UntrustedIdentityException { + throw new UnsupportedOperationException(); + } + + @Override + public SendMessageResults sendMessage( + final Message message, final Set recipients + ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + return handleMessage(recipients, + numbers -> signal.sendMessage(message.getMessageText(), message.getAttachments(), numbers), + () -> signal.sendNoteToSelfMessage(message.getMessageText(), message.getAttachments()), + groupId -> signal.sendGroupMessage(message.getMessageText(), message.getAttachments(), groupId)); + } + + @Override + public SendMessageResults sendRemoteDeleteMessage( + final long targetSentTimestamp, final Set recipients + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + return handleMessage(recipients, + numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers), + () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()), + groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId)); + } + + @Override + public SendMessageResults sendMessageReaction( + final String emoji, + final boolean remove, + final RecipientIdentifier.Single targetAuthor, + final long targetSentTimestamp, + final Set recipients + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { + return handleMessage(recipients, + numbers -> signal.sendMessageReaction(emoji, + remove, + targetAuthor.getIdentifier(), + targetSentTimestamp, + numbers), + () -> signal.sendMessageReaction(emoji, + remove, + targetAuthor.getIdentifier(), + targetSentTimestamp, + signal.getSelfNumber()), + groupId -> signal.sendGroupMessageReaction(emoji, + remove, + targetAuthor.getIdentifier(), + targetSentTimestamp, + groupId)); + } + + @Override + public SendMessageResults sendEndSessionMessage(final Set recipients) throws IOException { + signal.sendEndSessionMessage(recipients.stream() + .map(RecipientIdentifier.Single::getIdentifier) + .collect(Collectors.toList())); + return new SendMessageResults(0, Map.of()); + } + + @Override + public void setContactName( + final RecipientIdentifier.Single recipient, final String name + ) throws NotMasterDeviceException, UnregisteredUserException { + signal.setContactName(recipient.getIdentifier(), name); + } + + @Override + public void setContactBlocked( + final RecipientIdentifier.Single recipient, final boolean blocked + ) throws NotMasterDeviceException, IOException { + signal.setContactBlocked(recipient.getIdentifier(), blocked); + } + + @Override + public void setGroupBlocked( + final GroupId groupId, final boolean blocked + ) throws GroupNotFoundException, IOException { + signal.setGroupBlocked(groupId.serialize(), blocked); + } + + @Override + public void setExpirationTimer( + final RecipientIdentifier.Single recipient, final int messageExpirationTimer + ) throws IOException { + signal.setExpirationTimer(recipient.getIdentifier(), messageExpirationTimer); + } + + @Override + public URI uploadStickerPack(final File path) throws IOException, StickerPackInvalidException { + try { + return new URI(signal.uploadStickerPack(path.getPath())); + } catch (URISyntaxException e) { + throw new AssertionError(e); + } + } + + @Override + public void requestAllSyncData() throws IOException { + signal.sendSyncRequest(); + } + + @Override + public void receiveMessages( + final long timeout, + final TimeUnit unit, + final boolean returnOnTimeout, + final boolean ignoreAttachments, + final ReceiveMessageHandler handler + ) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean hasCaughtUpWithOldMessages() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isContactBlocked(final RecipientIdentifier.Single recipient) { + return signal.isContactBlocked(recipient.getIdentifier()); + } + + @Override + public File getAttachmentFile(final SignalServiceAttachmentRemoteId attachmentId) { + throw new UnsupportedOperationException(); + } + + @Override + public void sendContacts() throws IOException { + signal.sendContacts(); + } + + @Override + public List> getContacts() { + throw new UnsupportedOperationException(); + } + + @Override + public String getContactOrProfileName(final RecipientIdentifier.Single recipient) { + return signal.getContactName(recipient.getIdentifier()); + } + + @Override + public Group getGroup(final GroupId groupId) { + final var id = groupId.serialize(); + return new Group(groupId, + signal.getGroupName(id), + null, + null, + signal.getGroupMembers(id).stream().map(m -> new RecipientAddress(null, m)).collect(Collectors.toSet()), + Set.of(), + Set.of(), + Set.of(), + signal.isGroupBlocked(id), + 0, + false, + signal.isMember(id)); + } + + @Override + public List getIdentities() { + throw new UnsupportedOperationException(); + } + + @Override + public List getIdentities(final RecipientIdentifier.Single recipient) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean trustIdentityVerified(final RecipientIdentifier.Single recipient, final byte[] fingerprint) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean trustIdentityVerifiedSafetyNumber( + final RecipientIdentifier.Single recipient, final String safetyNumber + ) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean trustIdentityVerifiedSafetyNumber( + final RecipientIdentifier.Single recipient, final byte[] safetyNumber + ) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean trustIdentityAllKeys(final RecipientIdentifier.Single recipient) { + throw new UnsupportedOperationException(); + } + + @Override + public String computeSafetyNumber( + final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey + ) { + throw new UnsupportedOperationException(); + } + + @Override + public SignalServiceAddress resolveSignalServiceAddress(final SignalServiceAddress address) { + return address; + } + + @Override + public void close() throws IOException { + } + + private SendMessageResults handleMessage( + Set recipients, + Function, Long> recipientsHandler, + Supplier noteToSelfHandler, + Function groupHandler + ) { + long timestamp = 0; + final var singleRecipients = recipients.stream() + .filter(r -> r instanceof RecipientIdentifier.Single) + .map(RecipientIdentifier.Single.class::cast) + .map(RecipientIdentifier.Single::getIdentifier) + .collect(Collectors.toList()); + if (singleRecipients.size() > 0) { + timestamp = recipientsHandler.apply(singleRecipients); + } + + if (recipients.contains(RecipientIdentifier.NoteToSelf.INSTANCE)) { + timestamp = noteToSelfHandler.get(); + } + final var groupRecipients = recipients.stream() + .filter(r -> r instanceof RecipientIdentifier.Group) + .map(RecipientIdentifier.Group.class::cast) + .map(g -> g.groupId) + .collect(Collectors.toList()); + for (final var groupId : groupRecipients) { + timestamp = groupHandler.apply(groupId.serialize()); + } + return new SendMessageResults(timestamp, Map.of()); + } + + private String emptyIfNull(final String string) { + return string == null ? "" : string; + } + + private T getRemoteObject(final DBusPath devicePath, final Class type) { + try { + return connection.getRemoteObject(DbusConfig.getBusname(), devicePath.getPath(), type); + } catch (DBusException e) { + throw new AssertionError(e); + } + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusProperties.java b/src/main/java/org/asamk/signal/dbus/DbusProperties.java new file mode 100644 index 00000000..37cc35e3 --- /dev/null +++ b/src/main/java/org/asamk/signal/dbus/DbusProperties.java @@ -0,0 +1,66 @@ +package org.asamk.signal.dbus; + +import org.asamk.Signal; +import org.freedesktop.dbus.interfaces.Properties; +import org.freedesktop.dbus.types.Variant; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public abstract class DbusProperties implements Properties { + + private final List handlers = new ArrayList<>(); + + protected void addPropertiesHandler(DbusInterfacePropertiesHandler handler) { + this.handlers.add(handler); + } + + DbusInterfacePropertiesHandler getHandler(String interfaceName) { + final var handler = getHandlerOptional(interfaceName); + if (handler.isEmpty()) { + throw new Signal.Error.Failure("Property not found"); + } + return handler.get(); + } + + private java.util.Optional getHandlerOptional(final String interfaceName) { + return handlers.stream().filter(h -> h.getInterfaceName().equals(interfaceName)).findFirst(); + } + + @Override + @SuppressWarnings("unchecked") + public A Get(final String interface_name, final String property_name) { + final var handler = getHandler(interface_name); + final var getter = handler.getGetter(property_name); + if (getter == null) { + throw new Signal.Error.Failure("Property not found"); + } + return (A) getter.get(); + } + + @Override + public void Set(final String interface_name, final String property_name, final A value) { + final var handler = getHandler(interface_name); + final var setter = handler.getSetter(property_name); + if (setter == null) { + throw new Signal.Error.Failure("Property not found"); + } + setter.accept(value); + } + + @Override + public Map> GetAll(final String interface_name) { + final var handler = getHandlerOptional(interface_name); + if (handler.isEmpty()) { + return Map.of(); + } + + return handler.get() + .getProperties() + .stream() + .filter(p -> p.getGetter() != null) + .collect(Collectors.toMap(DbusProperty::getName, p -> new Variant<>(p.getGetter().get()))); + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusProperty.java b/src/main/java/org/asamk/signal/dbus/DbusProperty.java new file mode 100644 index 00000000..e0557786 --- /dev/null +++ b/src/main/java/org/asamk/signal/dbus/DbusProperty.java @@ -0,0 +1,35 @@ +package org.asamk.signal.dbus; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +public class DbusProperty { + + private final String name; + private final Supplier getter; + private final Consumer setter; + + public DbusProperty(final String name, final Supplier getter, final Consumer setter) { + this.name = name; + this.getter = getter; + this.setter = setter; + } + + public DbusProperty(final String name, final Supplier getter) { + this.name = name; + this.getter = getter; + this.setter = null; + } + + public String getName() { + return name; + } + + public Consumer getSetter() { + return setter; + } + + public Supplier getGetter() { + return getter; + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java index 6ec8d964..be628bde 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java @@ -160,7 +160,7 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl { synchronized (receiveThreads) { return receiveThreads.stream() .map(Pair::first) - .map(Manager::getUsername) + .map(Manager::getSelfNumber) .map(u -> new DBusPath(DbusConfig.getObjectPath(u))) .collect(Collectors.toList()); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 5e8fd432..ab9c89b2 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -5,7 +5,9 @@ import org.asamk.signal.BaseConfig; import org.asamk.signal.manager.AttachmentInvalidException; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.NotMasterDeviceException; +import org.asamk.signal.manager.StickerPackInvalidException; import org.asamk.signal.manager.UntrustedIdentityException; +import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.TypingAction; @@ -15,19 +17,26 @@ import org.asamk.signal.manager.groups.GroupNotFoundException; import org.asamk.signal.manager.groups.GroupSendingNotAllowedException; import org.asamk.signal.manager.groups.LastGroupAdminException; import org.asamk.signal.manager.groups.NotAGroupMemberException; -import org.asamk.signal.manager.storage.identities.IdentityInfo; +import org.asamk.signal.manager.storage.recipients.Profile; +import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.util.ErrorUtils; -import org.asamk.signal.util.Util; +import org.freedesktop.dbus.DBusPath; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.exceptions.DBusException; import org.freedesktop.dbus.exceptions.DBusExecutionException; +import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -35,24 +44,31 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.asamk.signal.util.Util.getLegacyIdentifier; - public class DbusSignalImpl implements Signal { private final Manager m; + private final DBusConnection connection; private final String objectPath; - public DbusSignalImpl(final Manager m, final String objectPath) { + private DBusPath thisDevice; + private final List devices = new ArrayList<>(); + + public DbusSignalImpl(final Manager m, DBusConnection connection, final String objectPath) { this.m = m; + this.connection = connection; this.objectPath = objectPath; } - @Override - public boolean isRemote() { - return false; + public void initObjects() { + updateDevices(); + } + + public void close() { + unExportDevices(); } @Override @@ -60,6 +76,72 @@ public class DbusSignalImpl implements Signal { return objectPath; } + @Override + public String getSelfNumber() { + return m.getSelfNumber(); + } + + @Override + public void addDevice(String uri) { + try { + m.addDeviceLink(new URI(uri)); + } catch (IOException | InvalidKeyException e) { + throw new Error.Failure(e.getClass().getSimpleName() + " Add device link failed. " + e.getMessage()); + } catch (URISyntaxException e) { + throw new Error.InvalidUri(e.getClass().getSimpleName() + + " Device link uri has invalid format: " + + e.getMessage()); + } + } + + @Override + public DBusPath getDevice(long deviceId) { + updateDevices(); + return new DBusPath(getDeviceObjectPath(objectPath, deviceId)); + } + + @Override + public List listDevices() { + updateDevices(); + return this.devices; + } + + private void updateDevices() { + List linkedDevices; + try { + linkedDevices = m.getLinkedDevices(); + } catch (IOException | Error.Failure e) { + throw new Error.Failure("Failed to get linked devices: " + e.getMessage()); + } + + unExportDevices(); + + linkedDevices.forEach(d -> { + final var object = new DbusSignalDeviceImpl(d); + final var deviceObjectPath = object.getObjectPath(); + try { + connection.exportObject(object); + } catch (DBusException e) { + e.printStackTrace(); + } + if (d.isThisDevice()) { + thisDevice = new DBusPath(deviceObjectPath); + } + this.devices.add(new DBusPath(deviceObjectPath)); + }); + } + + private void unExportDevices() { + this.devices.stream().map(DBusPath::getPath).forEach(connection::unExportObject); + this.devices.clear(); + } + + @Override + public DBusPath getThisDevice() { + updateDevices(); + return thisDevice; + } + @Override public long sendMessage(final String message, final List attachments, final String recipient) { var recipients = new ArrayList(1); @@ -71,7 +153,7 @@ public class DbusSignalImpl implements Signal { public long sendMessage(final String message, final List attachments, final List recipients) { try { final var results = m.sendMessage(new Message(message, attachments), - getSingleRecipientIdentifiers(recipients, m.getUsername()).stream() + getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) .collect(Collectors.toSet())); @@ -101,7 +183,7 @@ public class DbusSignalImpl implements Signal { ) { try { final var results = m.sendRemoteDeleteMessage(targetSentTimestamp, - getSingleRecipientIdentifiers(recipients, m.getUsername()).stream() + getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) .collect(Collectors.toSet())); checkSendMessageResults(results.getTimestamp(), results.getResults()); @@ -153,9 +235,9 @@ public class DbusSignalImpl implements Signal { try { final var results = m.sendMessageReaction(emoji, remove, - getSingleRecipientIdentifier(targetAuthor, m.getUsername()), + getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber()), targetSentTimestamp, - getSingleRecipientIdentifiers(recipients, m.getUsername()).stream() + getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) .collect(Collectors.toSet())); checkSendMessageResults(results.getTimestamp(), results.getResults()); @@ -175,7 +257,7 @@ public class DbusSignalImpl implements Signal { var recipients = new ArrayList(1); recipients.add(recipient); m.sendTypingMessage(stop ? TypingAction.STOP : TypingAction.START, - getSingleRecipientIdentifiers(recipients, m.getUsername()).stream() + getSingleRecipientIdentifiers(recipients, m.getSelfNumber()).stream() .map(RecipientIdentifier.class::cast) .collect(Collectors.toSet())); } catch (IOException e) { @@ -189,10 +271,10 @@ public class DbusSignalImpl implements Signal { @Override public void sendReadReceipt( - final String recipient, final List timestamps + final String recipient, final List messageIds ) throws Error.Failure, Error.UntrustedIdentity { try { - m.sendReadReceipt(getSingleRecipientIdentifier(recipient, m.getUsername()), timestamps); + m.sendReadReceipt(getSingleRecipientIdentifier(recipient, m.getSelfNumber()), messageIds); } catch (IOException e) { throw new Error.Failure(e.getMessage()); } catch (UntrustedIdentityException e) { @@ -200,13 +282,31 @@ public class DbusSignalImpl implements Signal { } } + @Override + public void sendContacts() { + try { + m.sendContacts(); + } catch (IOException e) { + throw new Error.Failure("SendContacts error: " + e.getMessage()); + } + } + + @Override + public void sendSyncRequest() { + try { + m.requestAllSyncData(); + } catch (IOException e) { + throw new Error.Failure("Request sync data error: " + e.getMessage()); + } + } + @Override public long sendNoteToSelfMessage( final String message, final List attachments ) throws Error.AttachmentInvalid, Error.Failure, Error.UntrustedIdentity { try { final var results = m.sendMessage(new Message(message, attachments), - Set.of(new RecipientIdentifier.NoteToSelf())); + Set.of(RecipientIdentifier.NoteToSelf.INSTANCE)); checkSendMessageResults(results.getTimestamp(), results.getResults()); return results.getTimestamp(); } catch (AttachmentInvalidException e) { @@ -221,7 +321,7 @@ public class DbusSignalImpl implements Signal { @Override public void sendEndSessionMessage(final List recipients) { try { - final var results = m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getUsername())); + final var results = m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getSelfNumber())); checkSendMessageResults(results.getTimestamp(), results.getResults()); } catch (IOException e) { throw new Error.Failure(e.getMessage()); @@ -255,7 +355,7 @@ public class DbusSignalImpl implements Signal { try { final var results = m.sendMessageReaction(emoji, remove, - getSingleRecipientIdentifier(targetAuthor, m.getUsername()), + getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber()), targetSentTimestamp, Set.of(new RecipientIdentifier.Group(getGroupId(groupId)))); checkSendMessageResults(results.getTimestamp(), results.getResults()); @@ -271,13 +371,14 @@ public class DbusSignalImpl implements Signal { // the profile name @Override public String getContactName(final String number) { - return m.getContactOrProfileName(getSingleRecipientIdentifier(number, m.getUsername())); + final var name = m.getContactOrProfileName(getSingleRecipientIdentifier(number, m.getSelfNumber())); + return name == null ? "" : name; } @Override public void setContactName(final String number, final String name) { try { - m.setContactName(getSingleRecipientIdentifier(number, m.getUsername()), name); + m.setContactName(getSingleRecipientIdentifier(number, m.getSelfNumber()), name); } catch (NotMasterDeviceException e) { throw new Error.Failure("This command doesn't work on linked devices."); } catch (UnregisteredUserException e) { @@ -285,10 +386,19 @@ public class DbusSignalImpl implements Signal { } } + @Override + public void setExpirationTimer(final String number, final int expiration) { + try { + m.setExpirationTimer(getSingleRecipientIdentifier(number, m.getSelfNumber()), expiration); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + @Override public void setContactBlocked(final String number, final boolean blocked) { try { - m.setContactBlocked(getSingleRecipientIdentifier(number, m.getUsername()), blocked); + m.setContactBlocked(getSingleRecipientIdentifier(number, m.getSelfNumber()), blocked); } catch (NotMasterDeviceException e) { throw new Error.Failure("This command doesn't work on linked devices."); } catch (IOException e) { @@ -320,7 +430,7 @@ public class DbusSignalImpl implements Signal { @Override public String getGroupName(final byte[] groupId) { var group = m.getGroup(getGroupId(groupId)); - if (group == null) { + if (group == null || group.getTitle() == null) { return ""; } else { return group.getTitle(); @@ -333,27 +443,17 @@ public class DbusSignalImpl implements Signal { if (group == null) { return List.of(); } else { - return group.getMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(Util::getLegacyIdentifier) - .collect(Collectors.toList()); + return group.getMembers().stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toList()); } } @Override public byte[] updateGroup(byte[] groupId, String name, List members, String avatar) { try { - if (groupId.length == 0) { - groupId = null; - } - if (name.isEmpty()) { - name = null; - } - if (avatar.isEmpty()) { - avatar = null; - } - final var memberIdentifiers = getSingleRecipientIdentifiers(members, m.getUsername()); + groupId = nullIfEmpty(groupId); + name = nullIfEmpty(name); + avatar = nullIfEmpty(avatar); + final var memberIdentifiers = getSingleRecipientIdentifiers(members, m.getSelfNumber()); if (groupId == null) { final var results = m.createGroup(name, memberIdentifiers, avatar == null ? null : new File(avatar)); checkSendMessageResults(results.second().getTimestamp(), results.second().getResults()); @@ -392,6 +492,56 @@ public class DbusSignalImpl implements Signal { return true; } + @Override + public boolean isRegistered(String number) { + var result = isRegistered(List.of(number)); + return result.get(0); + } + + @Override + public List isRegistered(List numbers) { + var results = new ArrayList(); + if (numbers.isEmpty()) { + return results; + } + + Map> registered; + try { + registered = m.areUsersRegistered(new HashSet<>(numbers)); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + + return numbers.stream().map(number -> { + var uuid = registered.get(number).second(); + return uuid != null; + }).collect(Collectors.toList()); + } + + @Override + public void updateProfile( + String givenName, + String familyName, + String about, + String aboutEmoji, + String avatarPath, + final boolean removeAvatar + ) { + try { + givenName = nullIfEmpty(givenName); + familyName = nullIfEmpty(familyName); + about = nullIfEmpty(about); + aboutEmoji = nullIfEmpty(aboutEmoji); + avatarPath = nullIfEmpty(avatarPath); + Optional avatarFile = removeAvatar + ? Optional.absent() + : avatarPath == null ? null : Optional.of(new File(avatarPath)); + m.setProfile(givenName, familyName, about, aboutEmoji, avatarFile); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + @Override public void updateProfile( final String name, @@ -400,16 +550,28 @@ public class DbusSignalImpl implements Signal { String avatarPath, final boolean removeAvatar ) { + updateProfile(name, "", about, aboutEmoji, avatarPath, removeAvatar); + } + + @Override + public void removePin() { try { - if (avatarPath.isEmpty()) { - avatarPath = null; - } - Optional avatarFile = removeAvatar - ? Optional.absent() - : avatarPath == null ? null : Optional.of(new File(avatarPath)); - m.setProfile(name, null, about, aboutEmoji, avatarFile); + m.setRegistrationLockPin(Optional.absent()); + } catch (UnauthenticatedResponseException e) { + throw new Error.Failure("Remove pin failed with unauthenticated response: " + e.getMessage()); } catch (IOException e) { - throw new Error.Failure(e.getMessage()); + throw new Error.Failure("Remove pin error: " + e.getMessage()); + } + } + + @Override + public void setPin(String registrationLockPin) { + try { + m.setRegistrationLockPin(Optional.of(registrationLockPin)); + } catch (UnauthenticatedResponseException e) { + throw new Error.Failure("Set pin error failed with unauthenticated response: " + e.getMessage()); + } catch (IOException e) { + throw new Error.Failure("Set pin error: " + e.getMessage()); } } @@ -424,10 +586,9 @@ public class DbusSignalImpl implements Signal { // all numbers the system knows @Override public List listNumbers() { - return Stream.concat(m.getIdentities().stream().map(IdentityInfo::getRecipientId), + return Stream.concat(m.getIdentities().stream().map(Identity::getRecipient), m.getContacts().stream().map(Pair::first)) - .map(m::resolveSignalServiceAddress) - .map(a -> a.getNumber().orNull()) + .map(a -> a.getNumber().orElse(null)) .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); @@ -440,16 +601,19 @@ public class DbusSignalImpl implements Signal { var contacts = m.getContacts(); for (var c : contacts) { if (name.equals(c.second().getName())) { - numbers.add(getLegacyIdentifier(m.resolveSignalServiceAddress(c.first()))); + numbers.add(c.first().getLegacyIdentifier()); } } // Try profiles if no contact name was found for (var identity : m.getIdentities()) { - final var recipientId = identity.getRecipientId(); - final var address = m.resolveSignalServiceAddress(recipientId); - var number = address.getNumber().orNull(); + final var address = identity.getRecipient(); + var number = address.getNumber().orElse(null); if (number != null) { - var profile = m.getRecipientProfile(recipientId); + Profile profile = null; + try { + profile = m.getRecipientProfile(RecipientIdentifier.Single.fromAddress(address)); + } catch (UnregisteredUserException ignored) { + } if (profile != null && profile.getDisplayName().equals(name)) { numbers.add(number); } @@ -490,7 +654,7 @@ public class DbusSignalImpl implements Signal { @Override public boolean isContactBlocked(final String number) { - return m.isContactBlocked(getSingleRecipientIdentifier(number, m.getUsername())); + return m.isContactBlocked(getSingleRecipientIdentifier(number, m.getSelfNumber())); } @Override @@ -509,7 +673,19 @@ public class DbusSignalImpl implements Signal { if (group == null) { return false; } else { - return group.isMember(m.getSelfRecipientId()); + return group.isMember(); + } + } + + @Override + public String uploadStickerPack(String stickerPackPath) { + File path = new File(stickerPackPath); + try { + return m.uploadStickerPack(path).toString(); + } catch (IOException e) { + throw new Error.Failure("Upload error (maybe image size is too large):" + e.getMessage()); + } catch (StickerPackInvalidException e) { + throw new Error.Failure("Invalid sticker pack: " + e.getMessage()); } } @@ -603,4 +779,61 @@ public class DbusSignalImpl implements Signal { throw new Error.InvalidGroupId("Invalid group id: " + e.getMessage()); } } + + private byte[] nullIfEmpty(final byte[] array) { + return array.length == 0 ? null : array; + } + + private String nullIfEmpty(final String name) { + return name.isEmpty() ? null : name; + } + + private static String getDeviceObjectPath(String basePath, long deviceId) { + return basePath + "/Devices/" + deviceId; + } + + public class DbusSignalDeviceImpl extends DbusProperties implements Signal.Device { + + private final org.asamk.signal.manager.api.Device device; + + public DbusSignalDeviceImpl(final org.asamk.signal.manager.api.Device device) { + super(); + super.addPropertiesHandler(new DbusInterfacePropertiesHandler("org.asamk.Signal.Device", + List.of(new DbusProperty<>("Id", device::getId), + new DbusProperty<>("Name", + () -> device.getName() == null ? "" : device.getName(), + this::setDeviceName), + new DbusProperty<>("Created", device::getCreated), + new DbusProperty<>("LastSeen", device::getLastSeen)))); + this.device = device; + } + + @Override + public String getObjectPath() { + return getDeviceObjectPath(objectPath, device.getId()); + } + + @Override + public void removeDevice() throws Error.Failure { + try { + m.removeLinkedDevices(device.getId()); + updateDevices(); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + + private void setDeviceName(String name) { + if (!device.isThisDevice()) { + throw new Error.Failure("Only the name of this device can be changed"); + } + try { + m.updateAccountAttributes(name); + // update device list + updateDevices(); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + } } diff --git a/src/main/java/org/asamk/signal/json/JsonMention.java b/src/main/java/org/asamk/signal/json/JsonMention.java index b24768b7..3c6f2eec 100644 --- a/src/main/java/org/asamk/signal/json/JsonMention.java +++ b/src/main/java/org/asamk/signal/json/JsonMention.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.asamk.signal.manager.Manager; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; import static org.asamk.signal.util.Util.getLegacyIdentifier; @@ -26,7 +27,7 @@ public class JsonMention { final int length; JsonMention(SignalServiceDataMessage.Mention mention, Manager m) { - final var address = m.resolveSignalServiceAddress(mention.getUuid()); + final var address = m.resolveSignalServiceAddress(new SignalServiceAddress(mention.getUuid())); this.name = getLegacyIdentifier(address); this.number = address.getNumber().orNull(); this.uuid = address.getUuid().toString(); diff --git a/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java b/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java index 7b884b0e..e49e6125 100644 --- a/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java +++ b/src/main/java/org/asamk/signal/json/JsonMessageEnvelope.java @@ -86,7 +86,7 @@ public class JsonMessageEnvelope { } String name; try { - name = m.getContactOrProfileName(RecipientIdentifier.Single.fromString(this.source, m.getUsername())); + name = m.getContactOrProfileName(RecipientIdentifier.Single.fromString(this.source, m.getSelfNumber())); } catch (InvalidNumberException | NullPointerException e) { name = null; } diff --git a/src/main/java/org/asamk/signal/util/CommandUtil.java b/src/main/java/org/asamk/signal/util/CommandUtil.java index 83674876..0a624e6b 100644 --- a/src/main/java/org/asamk/signal/util/CommandUtil.java +++ b/src/main/java/org/asamk/signal/util/CommandUtil.java @@ -25,10 +25,10 @@ public class CommandUtil { ) throws UserErrorException { final var recipientIdentifiers = new HashSet(); if (isNoteToSelf) { - recipientIdentifiers.add(new RecipientIdentifier.NoteToSelf()); + recipientIdentifiers.add(RecipientIdentifier.NoteToSelf.INSTANCE); } if (recipientStrings != null) { - final var localNumber = m.getUsername(); + final var localNumber = m.getSelfNumber(); recipientIdentifiers.addAll(CommandUtil.getSingleRecipientIdentifiers(recipientStrings, localNumber)); } if (groupIdStrings != null) {