/*
Copyright (C) 2015-2022 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.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLimitExceededException;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidNumberException;
import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.LastGroupAdminException;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.MessageEnvelope.Sync.MessageRequestResponse;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.NotPrimaryDeviceException;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.PendingAdminApprovalException;
import org.asamk.signal.manager.api.PhoneNumberSharingMode;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TextStyle;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.helper.RecipientHelper.RegisteredUser;
import org.asamk.signal.manager.jobs.RefreshRecipientsJob;
import org.asamk.signal.manager.jobs.SyncStorageJob;
import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.storage.AvatarStore;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
import org.asamk.signal.manager.storage.stickerPacks.StickerPackStore;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.PhoneNumberFormatter;
import org.asamk.signal.manager.util.StickerUtils;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import okio.Utf8;
import static org.asamk.signal.manager.config.ServiceConfig.MAX_MESSAGE_SIZE_BYTES;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.signal.core.util.StringExtensionsKt.splitByByteLength;
public class ManagerImpl implements Manager {
private static final Logger logger = LoggerFactory.getLogger(ManagerImpl.class);
private SignalAccount account;
private final SignalDependencies dependencies;
private final Context context;
private final ExecutorService executor = Executors.newCachedThreadPool();
private Thread receiveThread;
private boolean isReceivingSynchronous;
private final Set weakHandlers = new HashSet<>();
private final Set messageHandlers = new HashSet<>();
private final List closedListeners = new ArrayList<>();
private final List addressChangedListeners = new ArrayList<>();
private final CompositeDisposable disposable = new CompositeDisposable();
private final AtomicLong lastMessageTimestamp = new AtomicLong();
public ManagerImpl(
SignalAccount account,
PathConfig pathConfig,
AccountFileUpdater accountFileUpdater,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent
) {
this.account = account;
final var sessionLock = new ReentrantSignalSessionLock();
this.dependencies = new SignalDependencies(serviceEnvironmentConfig,
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
executor,
sessionLock);
final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
final var attachmentStore = new AttachmentStore(pathConfig.attachmentsPath());
final var stickerPackStore = new StickerPackStore(pathConfig.stickerPacksPath());
this.context = new Context(account, new AccountFileUpdater() {
@Override
public void updateAccountIdentifiers(final String number, final ACI aci) {
accountFileUpdater.updateAccountIdentifiers(number, aci);
synchronized (addressChangedListeners) {
addressChangedListeners.forEach(Runnable::run);
}
}
@Override
public void removeAccount() {
accountFileUpdater.removeAccount();
}
}, dependencies, avatarStore, attachmentStore, stickerPackStore);
this.context.getAccountHelper().setUnregisteredListener(this::close);
this.context.getReceiveHelper().setAuthenticationFailureListener(this::close);
this.context.getReceiveHelper().setCaughtUpWithOldMessagesListener(() -> {
synchronized (this) {
this.notifyAll();
}
});
disposable.add(account.getIdentityKeyStore()
.getIdentityChanges()
.observeOn(Schedulers.from(executor))
.subscribe(serviceId -> {
logger.trace("Archiving old sessions for {}", serviceId);
account.getAccountData(ServiceIdType.ACI).getSessionStore().archiveSessions(serviceId);
account.getAccountData(ServiceIdType.PNI).getSessionStore().archiveSessions(serviceId);
account.getSenderKeyStore().deleteSharedWith(serviceId);
final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
final var profile = account.getProfileStore().getProfile(recipientId);
if (profile != null) {
account.getProfileStore()
.storeProfile(recipientId,
Profile.newBuilder(profile)
.withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
.withLastUpdateTimestamp(0)
.build());
}
}));
}
@Override
public String getSelfNumber() {
return account.getNumber();
}
public void checkAccountState() throws IOException {
context.getAccountHelper().checkAccountState();
final var lastRecipientsRefresh = account.getLastRecipientsRefresh();
if (lastRecipientsRefresh == null
|| lastRecipientsRefresh < System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) {
context.getJobExecutor().enqueueJob(new RefreshRecipientsJob());
context.getAccountHelper().checkWhoAmiI();
}
}
@Override
public Map getUserStatus(Set numbers) throws IOException, RateLimitException {
final var canonicalizedNumbers = numbers.stream().collect(Collectors.toMap(n -> n, n -> {
try {
final var canonicalizedNumber = PhoneNumberFormatter.formatNumber(n, account.getNumber());
if (!canonicalizedNumber.equals(n)) {
logger.debug("Normalized number {} to {}.", n, canonicalizedNumber);
}
return canonicalizedNumber;
} catch (InvalidNumberException e) {
return "";
}
}));
// Note "registeredUsers" has no optionals. It only gives us info on users who are registered
final var canonicalizedNumbersSet = canonicalizedNumbers.values()
.stream()
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
final Map registeredUsers;
try {
registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet);
} catch (CdsiResourceExhaustedException e) {
logger.debug("CDSI resource exhausted: {}", e.getMessage());
throw new RateLimitException(System.currentTimeMillis() + e.getRetryAfterSeconds() * 1000L);
}
return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
final var number = canonicalizedNumbers.get(n);
final var user = registeredUsers.get(number);
final var serviceId = user == null ? null : user.getServiceId();
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UserStatus(number.isEmpty() ? null : number,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
&& profile.getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNRESTRICTED);
}));
}
@Override
public Map getUsernameStatus(Set usernames) throws IOException {
final var registeredUsers = new HashMap();
for (final var username : usernames) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipientByUsernameOrLink(username, true);
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
registeredUsers.put(username, address);
} catch (UnregisteredRecipientException e) {
// ignore
}
}
return usernames.stream().collect(Collectors.toMap(n -> n, username -> {
final var user = registeredUsers.get(username);
final var serviceId = user == null ? null : user.serviceId().orElse(null);
final var profile = serviceId == null
? null
: context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UsernameStatus(username,
serviceId == null ? null : serviceId.getRawUuid(),
profile != null
&& profile.getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNRESTRICTED);
}));
}
@Override
public void updateAccountAttributes(
String deviceName,
Boolean unrestrictedUnidentifiedSender,
final Boolean discoverableByNumber,
final Boolean numberSharing
) throws IOException {
if (deviceName != null) {
context.getAccountHelper().setDeviceName(deviceName);
}
if (unrestrictedUnidentifiedSender != null) {
account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedSender);
}
if (discoverableByNumber != null) {
account.getConfigurationStore().setPhoneNumberUnlisted(!discoverableByNumber);
}
if (numberSharing != null) {
account.getConfigurationStore()
.setPhoneNumberSharingMode(numberSharing
? PhoneNumberSharingMode.EVERYBODY
: PhoneNumberSharingMode.NOBODY);
}
context.getAccountHelper().updateAccountAttributes();
context.getAccountHelper().checkWhoAmiI();
}
@Override
public Configuration getConfiguration() {
final var configurationStore = account.getConfigurationStore();
return Configuration.from(configurationStore);
}
@Override
public void updateConfiguration(Configuration configuration) {
final var configurationStore = account.getConfigurationStore();
if (configuration.readReceipts().isPresent()) {
configurationStore.setReadReceipts(configuration.readReceipts().get());
}
if (configuration.unidentifiedDeliveryIndicators().isPresent()) {
configurationStore.setUnidentifiedDeliveryIndicators(configuration.unidentifiedDeliveryIndicators().get());
}
if (configuration.typingIndicators().isPresent()) {
configurationStore.setTypingIndicators(configuration.typingIndicators().get());
}
if (configuration.linkPreviews().isPresent()) {
configurationStore.setLinkPreviews(configuration.linkPreviews().get());
}
context.getSyncHelper().sendConfigurationMessage();
syncRemoteStorage();
}
@Override
public void updateProfile(UpdateProfile updateProfile) throws IOException {
context.getProfileHelper()
.setProfile(updateProfile.getGivenName(),
updateProfile.getFamilyName(),
updateProfile.getAbout(),
updateProfile.getAboutEmoji(),
updateProfile.isDeleteAvatar()
? Optional.empty()
: updateProfile.getAvatar() == null ? null : Optional.of(updateProfile.getAvatar()),
updateProfile.getMobileCoinAddress());
context.getSyncHelper().sendSyncFetchProfileMessage();
}
void refreshCurrentUsername() throws IOException, BaseUsernameException {
context.getAccountHelper().refreshCurrentUsername();
}
@Override
public String getUsername() {
return account.getUsername();
}
@Override
public UsernameLinkUrl getUsernameLink() {
return new UsernameLinkUrl(account.getUsernameLink());
}
@Override
public void setUsername(final String username) throws IOException, InvalidUsernameException {
try {
if (username.contains(".")) {
context.getAccountHelper().reserveExactUsername(username);
} else {
context.getAccountHelper().reserveUsernameFromNickname(username);
}
} catch (UsernameMalformedException e) {
throw new InvalidUsernameException("Username is malformed", e);
} catch (UsernameTakenException e) {
throw new InvalidUsernameException("Username is already registered", e);
} catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
}
}
@Override
public void deleteUsername() throws IOException {
context.getAccountHelper().deleteUsername();
}
@Override
public void startChangeNumber(
String newNumber,
boolean voiceVerification,
String captcha
) throws RateLimitException, IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, NotPrimaryDeviceException, VerificationMethodNotAvailableException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getAccountHelper().startChangeNumber(newNumber, voiceVerification, captcha);
}
@Override
public void finishChangeNumber(
String newNumber,
String verificationCode,
String pin
) throws IncorrectPinException, PinLockedException, IOException, NotPrimaryDeviceException, PinLockMissingException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getAccountHelper().finishChangeNumber(newNumber, verificationCode, pin);
}
@Override
public void unregister() throws IOException {
context.getAccountHelper().unregister();
}
@Override
public void deleteAccount() throws IOException {
context.getAccountHelper().deleteAccount();
}
@Override
public void submitRateLimitRecaptchaChallenge(
String challenge,
String captcha
) throws IOException, CaptchaRejectedException {
captcha = captcha == null ? "" : captcha.replace("signalcaptcha://", "");
try {
handleResponseException(dependencies.getRateLimitChallengeApi().submitCaptchaChallenge(challenge, captcha));
} catch (org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException ignored) {
throw new CaptchaRejectedException();
}
}
@Override
public List getLinkedDevices() throws IOException {
var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
account.setMultiDevice(devices.size() > 1);
var identityKey = account.getAciIdentityKeyPair().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());
}).toList();
}
@Override
public void removeLinkedDevices(int deviceId) throws IOException, NotPrimaryDeviceException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getAccountHelper().removeLinkedDevices(deviceId);
}
@Override
public void addDeviceLink(DeviceLinkUrl linkUrl) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException, DeviceLimitExceededException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getAccountHelper().addDevice(linkUrl);
}
@Override
public void setRegistrationLockPin(Optional pin) throws IOException, NotPrimaryDeviceException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
if (pin.isPresent()) {
context.getAccountHelper().setRegistrationPin(pin.get());
} else {
context.getAccountHelper().removeRegistrationPin();
}
}
void refreshPreKeys() throws IOException {
context.getPreKeyHelper().refreshPreKeysIfNecessary();
}
@Override
public List getGroups() {
return context.getGroupHelper().getGroups().stream().map(this::toGroup).toList();
}
private Group toGroup(final GroupInfo groupInfo) {
if (groupInfo == null) {
return null;
}
return Group.from(groupInfo, account.getRecipientAddressResolver(), account.getSelfRecipientId());
}
@Override
public SendGroupMessageResults quitGroup(
GroupId groupId,
Set groupAdmins
) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException, UnregisteredRecipientException {
final var newAdmins = context.getRecipientHelper().resolveRecipients(groupAdmins);
return context.getGroupHelper().quitGroup(groupId, newAdmins);
}
@Override
public void deleteGroup(GroupId groupId) throws IOException {
final var group = context.getGroupHelper().getGroup(groupId);
if (group.isMember(account.getSelfRecipientId())) {
throw new IOException(
"The local group information cannot be removed, as the user is still a member of the group");
}
context.getGroupHelper().deleteGroup(groupId);
}
@Override
public Pair createGroup(
String name,
Set members,
String avatarFile
) throws IOException, AttachmentInvalidException, UnregisteredRecipientException {
return context.getGroupHelper()
.createGroup(name,
members == null ? null : context.getRecipientHelper().resolveRecipients(members),
avatarFile);
}
@Override
public SendGroupMessageResults updateGroup(
final GroupId groupId,
final UpdateGroup updateGroup
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException, UnregisteredRecipientException {
return context.getGroupHelper()
.updateGroup(groupId,
updateGroup.getName(),
updateGroup.getDescription(),
updateGroup.getMembers() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getMembers()),
updateGroup.getRemoveMembers() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getRemoveMembers()),
updateGroup.getAdmins() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getAdmins()),
updateGroup.getRemoveAdmins() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getRemoveAdmins()),
updateGroup.getBanMembers() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getBanMembers()),
updateGroup.getUnbanMembers() == null
? null
: context.getRecipientHelper().resolveRecipients(updateGroup.getUnbanMembers()),
updateGroup.isResetGroupLink(),
updateGroup.getGroupLinkState(),
updateGroup.getAddMemberPermission(),
updateGroup.getEditDetailsPermission(),
updateGroup.getAvatarFile(),
updateGroup.getExpirationTimer(),
updateGroup.getIsAnnouncementGroup());
}
@Override
public Pair joinGroup(
GroupInviteLinkUrl inviteLinkUrl
) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
return context.getGroupHelper().joinGroup(inviteLinkUrl);
}
private long getNextMessageTimestamp() {
while (true) {
final var last = lastMessageTimestamp.get();
final var timestamp = System.currentTimeMillis();
if (last == timestamp) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
continue;
}
if (lastMessageTimestamp.compareAndSet(last, timestamp)) {
return timestamp;
}
}
}
private SendMessageResults sendMessage(
SignalServiceDataMessage.Builder messageBuilder,
Set recipients,
boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty());
}
private SendMessageResults sendMessage(
SignalServiceDataMessage.Builder messageBuilder,
Set recipients,
boolean notifySelf,
Optional editTargetTimestamp
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var results = new HashMap>();
long timestamp = getNextMessageTimestamp();
messageBuilder.withTimestamp(timestamp);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.NoteToSelf || (
recipient instanceof RecipientIdentifier.Single single
&& new RecipientAddress(single.toPartialRecipientAddress()).matches(account.getSelfRecipientAddress())
)) {
final var result = notifySelf
? context.getSendHelper()
.sendMessage(messageBuilder, account.getSelfRecipientId(), editTargetTimestamp)
: context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
results.put(recipient, List.of(toSendMessageResult(result)));
} else if (recipient instanceof RecipientIdentifier.Single single) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(single);
final var result = context.getSendHelper()
.sendMessage(messageBuilder, recipientId, editTargetTimestamp);
results.put(recipient, List.of(toSendMessageResult(result)));
} catch (UnregisteredRecipientException e) {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
final var result = context.getSendHelper()
.sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp);
results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
}
}
return new SendMessageResults(timestamp, results);
}
private SendMessageResult toSendMessageResult(final org.whispersystems.signalservice.api.messages.SendMessageResult result) {
return SendMessageResult.from(result, account.getRecipientResolver(), account.getRecipientAddressResolver());
}
private SendMessageResults sendTypingMessage(
SignalServiceTypingMessage.Action action,
Set recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var results = new HashMap>();
final var timestamp = getNextMessageTimestamp();
for (var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Single single) {
final var message = new SignalServiceTypingMessage(action, timestamp, Optional.empty());
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(single);
final var result = context.getSendHelper().sendTypingMessage(message, recipientId);
results.put(recipient, List.of(toSendMessageResult(result)));
} catch (UnregisteredRecipientException e) {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group) {
final var groupId = ((RecipientIdentifier.Group) recipient).groupId();
final var message = new SignalServiceTypingMessage(action, timestamp, Optional.of(groupId.serialize()));
final var result = context.getSendHelper().sendGroupTypingMessage(message, groupId);
results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
}
}
return new SendMessageResults(timestamp, results);
}
@Override
public SendMessageResults sendTypingMessage(
TypingAction action,
Set recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
return sendTypingMessage(action.toSignalService(), recipients);
}
@Override
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single sender, List messageIds) {
final var timestamp = getNextMessageTimestamp();
var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ,
messageIds,
timestamp);
return sendReceiptMessage(sender, timestamp, receiptMessage);
}
@Override
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single sender, List messageIds) {
final var timestamp = getNextMessageTimestamp();
var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED,
messageIds,
timestamp);
return sendReceiptMessage(sender, timestamp, receiptMessage);
}
private SendMessageResults sendReceiptMessage(
final RecipientIdentifier.Single sender,
final long timestamp,
final SignalServiceReceiptMessage receiptMessage
) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(sender);
final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
final var serviceId = account.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.serviceId();
if (serviceId.isPresent()) {
context.getSyncHelper().sendSyncReceiptMessage(serviceId.get(), receiptMessage);
}
return new SendMessageResults(timestamp, Map.of(sender, List.of(toSendMessageResult(result))));
} catch (UnregisteredRecipientException e) {
return new SendMessageResults(timestamp,
Map.of(sender, List.of(SendMessageResult.unregisteredFailure(sender.toPartialRecipientAddress()))));
}
}
@Override
public SendMessageResults sendMessage(
Message message,
Set recipients,
boolean notifySelf
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
final var selfProfile = context.getProfileHelper().getSelfProfile();
if (selfProfile == null || selfProfile.getDisplayName().isEmpty()) {
logger.warn(
"No profile name set. When sending a message it's recommended to set a profile name with the updateProfile command. This may become mandatory in the future.");
}
final var messageBuilder = SignalServiceDataMessage.newBuilder();
applyMessage(messageBuilder, message);
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendEditMessage(
Message message,
Set recipients,
long editTargetTimestamp
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
final var messageBuilder = SignalServiceDataMessage.newBuilder();
applyMessage(messageBuilder, message);
return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp));
}
private void applyMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final Message message
) throws AttachmentInvalidException, IOException, UnregisteredRecipientException, InvalidStickerException {
final var additionalAttachments = new ArrayList();
if (Utf8.size(message.messageText()) > MAX_MESSAGE_SIZE_BYTES) {
final var result = splitByByteLength(message.messageText(), MAX_MESSAGE_SIZE_BYTES);
final var trimmed = result.getFirst();
final var remainder = result.getSecond();
if (remainder != null) {
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT,
messageBytes.length);
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
messageBuilder.withBody(trimmed);
additionalAttachments.add(context.getAttachmentHelper().uploadAttachment(textAttachment));
} else {
messageBuilder.withBody(message.messageText());
}
} else {
messageBuilder.withBody(message.messageText());
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);
} else {
messageBuilder.withAttachments(uploadedAttachments);
}
} else if (!additionalAttachments.isEmpty()) {
messageBuilder.withAttachments(additionalAttachments);
}
messageBuilder.withViewOnce(message.viewOnce());
if (!message.mentions().isEmpty()) {
messageBuilder.withMentions(resolveMentions(message.mentions()));
}
if (!message.textStyles().isEmpty()) {
messageBuilder.withBodyRanges(message.textStyles().stream().map(TextStyle::toBodyRange).toList());
}
if (message.quote().isPresent()) {
final var quote = message.quote().get();
final var quotedAttachments = new ArrayList();
for (final var a : quote.attachments()) {
final var quotedAttachment = new SignalServiceDataMessage.Quote.QuotedAttachment(a.contentType(),
a.filename(),
a.preview() == null ? null : context.getAttachmentHelper().uploadAttachment(a.preview()));
quotedAttachments.add(quotedAttachment);
}
messageBuilder.withQuote(new SignalServiceDataMessage.Quote(quote.timestamp(),
context.getRecipientHelper()
.resolveSignalServiceAddress(context.getRecipientHelper().resolveRecipient(quote.author()))
.getServiceId(),
quote.message(),
quotedAttachments,
resolveMentions(quote.mentions()),
SignalServiceDataMessage.Quote.Type.NORMAL,
quote.textStyles().stream().map(TextStyle::toBodyRange).toList()));
}
if (message.sticker().isPresent()) {
final var sticker = message.sticker().get();
final var packId = StickerPackId.deserialize(sticker.packId());
final var stickerId = sticker.stickerId();
final var stickerPack = context.getAccount().getStickerStore().getStickerPack(packId);
if (stickerPack == null) {
throw new InvalidStickerException("Sticker pack not found");
}
final var manifest = context.getStickerHelper().getOrRetrieveStickerPack(packId, stickerPack.packKey());
if (manifest.stickers().size() <= stickerId) {
throw new InvalidStickerException("Sticker id not part of this pack");
}
final var manifestSticker = manifest.stickers().get(stickerId);
final var streamDetails = context.getStickerPackStore().retrieveSticker(packId, stickerId);
if (streamDetails == null) {
throw new InvalidStickerException("Missing local sticker file");
}
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(),
uploadSpec);
messageBuilder.withSticker(new SignalServiceDataMessage.Sticker(packId.serialize(),
stickerPack.packKey(),
stickerId,
manifestSticker.emoji(),
stickerAttachment));
}
if (!message.previews().isEmpty()) {
final var previews = new ArrayList(message.previews().size());
for (final var p : message.previews()) {
final var image = p.image().isPresent() ? context.getAttachmentHelper()
.uploadAttachment(p.image().get()) : null;
previews.add(new SignalServicePreview(p.url(),
p.title(),
p.description(),
0,
Optional.ofNullable(image)));
}
messageBuilder.withPreviews(previews);
}
if (message.storyReply().isPresent()) {
final var storyReply = message.storyReply().get();
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(context.getRecipientHelper().resolveRecipient(storyReply.author()))
.getServiceId();
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
storyReply.timestamp()));
}
}
private ArrayList resolveMentions(final List mentionList) throws UnregisteredRecipientException {
final var mentions = new ArrayList();
for (final var m : mentionList) {
final var recipientId = context.getRecipientHelper().resolveRecipient(m.recipient());
mentions.add(new SignalServiceDataMessage.Mention(context.getRecipientHelper()
.resolveSignalServiceAddress(recipientId)
.getServiceId(), m.start(), m.length()));
}
return mentions;
}
@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);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid u) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid()));
} else if (recipient instanceof RecipientIdentifier.Pni pni) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
} else if (recipient instanceof RecipientIdentifier.Single r) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r);
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
if (address.serviceId().isPresent()) {
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, address.serviceId().get());
}
} catch (UnregisteredRecipientException ignored) {
}
} else if (recipient instanceof RecipientIdentifier.Group r) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId());
}
}
return sendMessage(messageBuilder, recipients, false);
}
@Override
public SendMessageResults sendMessageReaction(
String emoji,
boolean remove,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set recipients,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
var reaction = new SignalServiceDataMessage.Reaction(emoji, remove, authorServiceId, targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder, recipients, false);
}
@Override
public SendMessageResults sendPaymentNotificationMessage(
byte[] receipt,
String note,
RecipientIdentifier.Single recipient
) throws IOException {
final var paymentNotification = new SignalServiceDataMessage.PaymentNotification(receipt, note);
final var payment = new SignalServiceDataMessage.Payment(paymentNotification, null);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPayment(payment);
try {
return sendMessage(messageBuilder, Set.of(recipient), false);
} catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
}
}
@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()),
false);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
} finally {
for (var recipient : recipients) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
continue;
}
final var serviceId = context.getAccount()
.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.serviceId();
if (serviceId.isPresent()) {
account.getAccountData(ServiceIdType.ACI).getSessionStore().deleteAllSessions(serviceId.get());
}
}
}
}
@Override
public SendMessageResults sendMessageRequestResponse(
final MessageRequestResponse.Type type,
final Set recipients
) {
var results = new HashMap>();
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.NoteToSelf || (
recipient instanceof RecipientIdentifier.Single single
&& new RecipientAddress(single.toPartialRecipientAddress()).matches(account.getSelfRecipientAddress())
)) {
final var result = context.getSyncHelper()
.sendMessageRequestResponse(type, account.getSelfRecipientId());
if (result != null) {
results.put(recipient, List.of(toSendMessageResult(result)));
}
results.put(recipient, List.of(toSendMessageResult(result)));
} else if (recipient instanceof RecipientIdentifier.Single single) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(single);
final var result = context.getSyncHelper().sendMessageRequestResponse(type, recipientId);
if (result != null) {
results.put(recipient, List.of(toSendMessageResult(result)));
}
} catch (UnregisteredRecipientException e) {
results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
}
} else if (recipient instanceof RecipientIdentifier.Group group) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId());
results.put(recipient, List.of(toSendMessageResult(result)));
}
}
return new SendMessageResults(0, results);
}
@Override
public void hideRecipient(final RecipientIdentifier.Single recipient) {
final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
context.getContactHelper().setContactHidden(recipientIdOptional.get(), true);
account.removeRecipient(recipientIdOptional.get());
syncRemoteStorage();
}
}
@Override
public void deleteRecipient(final RecipientIdentifier.Single recipient) {
final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
account.removeRecipient(recipientIdOptional.get());
syncRemoteStorage();
}
}
@Override
public void deleteContact(final RecipientIdentifier.Single recipient) {
final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
account.getContactStore().deleteContact(recipientIdOptional.get());
syncRemoteStorage();
}
}
@Override
public void setContactName(
final RecipientIdentifier.Single recipient,
final String givenName,
final String familyName,
final String nickGivenName,
final String nickFamilyName,
final String note
) throws NotPrimaryDeviceException, UnregisteredRecipientException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getContactHelper()
.setContactName(context.getRecipientHelper().resolveRecipient(recipient),
givenName,
familyName,
nickGivenName,
nickFamilyName,
note);
syncRemoteStorage();
}
@Override
public void setContactsBlocked(
Collection recipients,
boolean blocked
) throws IOException, UnregisteredRecipientException {
if (recipients.isEmpty()) {
return;
}
final var recipientIds = context.getRecipientHelper().resolveRecipients(recipients);
final var selfRecipientId = account.getSelfRecipientId();
boolean shouldRotateProfileKey = false;
for (final var recipientId : recipientIds) {
if (context.getContactHelper().isContactBlocked(recipientId) == blocked) {
continue;
}
context.getContactHelper().setContactBlocked(recipientId, blocked);
context.getSyncHelper()
.sendMessageRequestResponse(blocked
? MessageRequestResponse.Type.BLOCK
: MessageRequestResponse.Type.UNBLOCK_AND_ACCEPT, recipientId);
// if we don't have a common group with the blocked contact we need to rotate the profile key
shouldRotateProfileKey = blocked && (
shouldRotateProfileKey || account.getGroupStore()
.getGroups()
.stream()
.noneMatch(g -> g.isMember(selfRecipientId) && g.isMember(recipientId))
);
}
if (shouldRotateProfileKey) {
context.getProfileHelper().rotateProfileKey();
}
context.getSyncHelper().sendBlockedList();
syncRemoteStorage();
}
@Override
public void setGroupsBlocked(
final Collection groupIds,
final boolean blocked
) throws GroupNotFoundException, IOException {
if (groupIds.isEmpty()) {
return;
}
boolean shouldRotateProfileKey = false;
for (final var groupId : groupIds) {
if (context.getGroupHelper().isGroupBlocked(groupId) == blocked) {
continue;
}
context.getGroupHelper().setGroupBlocked(groupId, blocked);
context.getSyncHelper()
.sendMessageRequestResponse(blocked
? MessageRequestResponse.Type.BLOCK
: MessageRequestResponse.Type.UNBLOCK_AND_ACCEPT, groupId);
shouldRotateProfileKey = blocked;
}
if (shouldRotateProfileKey) {
context.getProfileHelper().rotateProfileKey();
}
context.getSyncHelper().sendBlockedList();
syncRemoteStorage();
}
@Override
public void setExpirationTimer(
RecipientIdentifier.Single recipient,
int messageExpirationTimer
) throws IOException, UnregisteredRecipientException {
var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
context.getContactHelper().setExpirationTimer(recipientId, messageExpirationTimer);
final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
try {
sendMessage(messageBuilder, Set.of(recipient), false);
} catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
}
syncRemoteStorage();
}
@Override
public StickerPackUrl 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 StickerPack(packId, packKey);
account.getStickerStore().addStickerPack(sticker);
context.getSyncHelper().sendStickerOperationsMessage(List.of(sticker), List.of());
return new StickerPackUrl(packId, packKey);
}
@Override
public void installStickerPack(StickerPackUrl url) throws IOException {
final var packId = url.packId();
final var packKey = url.packKey();
try {
context.getStickerHelper().retrieveStickerPack(packId, packKey);
} catch (InvalidMessageException e) {
throw new IOException(e);
}
final var sticker = context.getStickerHelper().addOrUpdateStickerPack(packId, packKey, true);
context.getSyncHelper().sendStickerOperationsMessage(List.of(sticker), List.of());
}
@Override
public List getStickerPacks() {
final var stickerPackStore = context.getStickerPackStore();
return account.getStickerStore().getStickerPacks().stream().map(pack -> {
if (stickerPackStore.existsStickerPack(pack.packId())) {
try {
final var manifest = stickerPackStore.retrieveManifest(pack.packId());
return new org.asamk.signal.manager.api.StickerPack(pack.packId(),
new StickerPackUrl(pack.packId(), pack.packKey()),
pack.isInstalled(),
manifest.title(),
manifest.author(),
Optional.ofNullable(manifest.cover() == null ? null : manifest.cover().toApi()),
manifest.stickers().stream().map(JsonStickerPack.JsonSticker::toApi).toList());
} catch (Exception e) {
logger.warn("Failed to read local sticker pack manifest: {}", e.getMessage(), e);
}
}
return new org.asamk.signal.manager.api.StickerPack(pack.packId(), pack.packKey(), pack.isInstalled());
}).toList();
}
@Override
public void requestAllSyncData() {
context.getSyncHelper().requestAllSyncData();
syncRemoteStorage();
}
void syncRemoteStorage() {
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
@Override
public void addReceiveHandler(final ReceiveMessageHandler handler, final boolean isWeakListener) {
synchronized (messageHandlers) {
if (isWeakListener) {
weakHandlers.add(handler);
} else {
messageHandlers.add(handler);
startReceiveThreadIfRequired();
}
}
}
private static final AtomicInteger threadNumber = new AtomicInteger(0);
private void startReceiveThreadIfRequired() {
if (receiveThread != null || isReceivingSynchronous) {
return;
}
receiveThread = Thread.ofPlatform().name("receive-" + threadNumber.getAndIncrement()).start(() -> {
logger.debug("Starting receiving messages");
context.getReceiveHelper().receiveMessagesContinuously(this::passReceivedMessageToHandlers);
logger.debug("Finished receiving messages");
synchronized (messageHandlers) {
receiveThread = null;
// Check if in the meantime another handler has been registered
if (!messageHandlers.isEmpty()) {
logger.debug("Another handler has been registered, starting receive thread again");
startReceiveThreadIfRequired();
}
}
});
}
private void passReceivedMessageToHandlers(MessageEnvelope envelope, Throwable e) {
synchronized (messageHandlers) {
Stream.concat(messageHandlers.stream(), weakHandlers.stream()).forEach(h -> {
try {
h.handleMessage(envelope, e);
} catch (Throwable ex) {
logger.warn("Message handler failed, ignoring", ex);
}
});
}
}
@Override
public void removeReceiveHandler(final ReceiveMessageHandler handler) {
final Thread thread;
synchronized (messageHandlers) {
weakHandlers.remove(handler);
messageHandlers.remove(handler);
if (!messageHandlers.isEmpty() || receiveThread == null || isReceivingSynchronous) {
return;
}
thread = receiveThread;
receiveThread = null;
}
stopReceiveThread(thread);
}
private void stopReceiveThread(final Thread thread) {
if (context.getReceiveHelper().requestStopReceiveMessages()) {
logger.debug("Receive stop requested, interrupting read from server.");
thread.interrupt();
}
try {
thread.join();
} catch (InterruptedException ignored) {
}
}
@Override
public boolean isReceiving() {
if (isReceivingSynchronous) {
return true;
}
synchronized (messageHandlers) {
return !messageHandlers.isEmpty();
}
}
@Override
public void receiveMessages(
Optional timeout,
Optional maxMessages,
ReceiveMessageHandler handler
) throws IOException, AlreadyReceivingException {
receiveMessages(timeout.orElse(Duration.ofMinutes(1)), timeout.isPresent(), maxMessages.orElse(null), handler);
}
@Override
public void stopReceiveMessages() {
Thread thread = null;
synchronized (messageHandlers) {
if (isReceivingSynchronous) {
thread = receiveThread;
receiveThread = null;
}
}
if (thread != null) {
stopReceiveThread(thread);
}
}
private void receiveMessages(
Duration timeout,
boolean returnOnTimeout,
Integer maxMessages,
ReceiveMessageHandler handler
) throws IOException, AlreadyReceivingException {
synchronized (messageHandlers) {
if (isReceiving()) {
throw new AlreadyReceivingException("Already receiving message.");
}
isReceivingSynchronous = true;
receiveThread = Thread.currentThread();
}
try {
context.getReceiveHelper().receiveMessages(timeout, returnOnTimeout, maxMessages, (envelope, e) -> {
passReceivedMessageToHandlers(envelope, e);
handler.handleMessage(envelope, e);
});
} finally {
synchronized (messageHandlers) {
receiveThread = null;
isReceivingSynchronous = false;
if (!messageHandlers.isEmpty()) {
startReceiveThreadIfRequired();
}
}
}
}
@Override
public void setReceiveConfig(final ReceiveConfig receiveConfig) {
context.getReceiveHelper().setReceiveConfig(receiveConfig);
}
@Override
public boolean isContactBlocked(final RecipientIdentifier.Single recipient) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
return false;
}
return context.getContactHelper().isContactBlocked(recipientId);
}
@Override
public void sendContacts() throws IOException {
context.getSyncHelper().sendContacts();
}
@Override
public List getRecipients(
boolean onlyContacts,
Optional blocked,
Collection recipients,
Optional name
) {
final var recipientIds = recipients.stream().map(a -> {
try {
return context.getRecipientHelper().resolveRecipient(a);
} catch (UnregisteredRecipientException e) {
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toSet());
if (!recipients.isEmpty() && recipientIds.isEmpty()) {
return List.of();
}
// refresh profiles of explicitly given recipients
context.getProfileHelper().refreshRecipientProfiles(recipientIds);
return account.getRecipientStore()
.getRecipients(onlyContacts, blocked, recipientIds, name)
.stream()
.map(s -> new Recipient(s.getRecipientId(),
s.getAddress().toApiRecipientAddress(),
s.getContact(),
s.getProfileKey(),
s.getExpiringProfileKeyCredential(),
s.getProfile(),
s.getDiscoverable()))
.toList();
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single recipient) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
return null;
}
final var contact = account.getContactStore().getContact(recipientId);
if (contact != null && !Util.isEmpty(contact.getName())) {
return contact.getName();
}
final var profile = context.getProfileHelper().getRecipientProfile(recipientId);
if (profile != null) {
return profile.getDisplayName();
}
return null;
}
@Override
public Group getGroup(GroupId groupId) {
return toGroup(context.getGroupHelper().getGroup(groupId));
}
@Override
public List getIdentities() {
return account.getIdentityKeyStore()
.getIdentities()
.stream()
.map(this::toIdentity)
.filter(Objects::nonNull)
.toList();
}
private Identity toIdentity(final IdentityInfo identityInfo) {
if (identityInfo == null) {
return null;
}
final var address = account.getRecipientAddressResolver()
.resolveRecipientAddress(account.getRecipientResolver().resolveRecipient(identityInfo.getServiceId()));
if (address.serviceId().isPresent() && !Objects.equals(address.serviceId().get(),
identityInfo.getServiceId())) {
return null;
}
final var scannableFingerprint = context.getIdentityHelper()
.computeSafetyNumberForScanning(identityInfo.getServiceId(), identityInfo.getIdentityKey());
return new Identity(address.toApiRecipientAddress(),
identityInfo.getIdentityKey().getPublicKey().serialize(),
context.getIdentityHelper()
.computeSafetyNumber(identityInfo.getServiceId(), identityInfo.getIdentityKey()),
scannableFingerprint == null ? null : scannableFingerprint.getSerialized(),
identityInfo.getTrustLevel(),
identityInfo.getDateAddedTimestamp());
}
@Override
public List getIdentities(RecipientIdentifier.Single recipient) {
ServiceId serviceId;
try {
final var address = account.getRecipientAddressResolver()
.resolveRecipientAddress(context.getRecipientHelper().resolveRecipient(recipient));
if (address.serviceId().isEmpty()) {
return List.of();
}
serviceId = address.serviceId().get();
} catch (UnregisteredRecipientException e) {
return List.of();
}
final var identity = account.getIdentityKeyStore().getIdentityInfo(serviceId);
return identity == null ? List.of() : List.of(toIdentity(identity));
}
@Override
public boolean trustIdentityVerified(
RecipientIdentifier.Single recipient,
IdentityVerificationCode verificationCode
) throws UnregisteredRecipientException {
return switch (verificationCode) {
case IdentityVerificationCode.Fingerprint fingerprint -> trustIdentity(recipient,
r -> context.getIdentityHelper().trustIdentityVerified(r, fingerprint.fingerprint()));
case IdentityVerificationCode.SafetyNumber safetyNumber -> trustIdentity(recipient,
r -> context.getIdentityHelper().trustIdentityVerifiedSafetyNumber(r, safetyNumber.safetyNumber()));
case IdentityVerificationCode.ScannableSafetyNumber safetyNumber -> trustIdentity(recipient,
r -> context.getIdentityHelper().trustIdentityVerifiedSafetyNumber(r, safetyNumber.safetyNumber()));
case null -> throw new AssertionError("Invalid verification code type");
};
}
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) throws UnregisteredRecipientException {
return trustIdentity(recipient, r -> context.getIdentityHelper().trustIdentityAllKeys(r));
}
private boolean trustIdentity(
RecipientIdentifier.Single recipient,
Function trustMethod
) throws UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var updated = trustMethod.apply(recipientId);
if (updated && this.isReceiving()) {
account.setNeedsToRetryFailedMessages(true);
}
return updated;
}
@Override
public void addAddressChangedListener(final Runnable listener) {
synchronized (addressChangedListeners) {
addressChangedListeners.add(listener);
}
}
@Override
public void addClosedListener(final Runnable listener) {
synchronized (closedListeners) {
closedListeners.add(listener);
}
}
@Override
public InputStream retrieveAttachment(final String id) throws IOException {
return context.getAttachmentHelper().retrieveAttachment(id).getStream();
}
@Override
public InputStream retrieveContactAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
final var streamDetails = context.getAvatarStore().retrieveContactAvatar(address);
if (streamDetails == null) {
throw new FileNotFoundException();
}
return streamDetails.getStream();
}
@Override
public InputStream retrieveProfileAvatar(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
context.getProfileHelper().getRecipientProfile(recipientId);
final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
final var streamDetails = context.getAvatarStore().retrieveProfileAvatar(address);
if (streamDetails == null) {
throw new FileNotFoundException();
}
return streamDetails.getStream();
}
@Override
public InputStream retrieveGroupAvatar(final GroupId groupId) throws IOException {
final var streamDetails = context.getAvatarStore().retrieveGroupAvatar(groupId);
context.getGroupHelper().getGroup(groupId);
if (streamDetails == null) {
throw new FileNotFoundException();
}
return streamDetails.getStream();
}
@Override
public InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException {
var streamDetails = context.getStickerPackStore().retrieveSticker(stickerPackId, stickerId);
if (streamDetails == null) {
final var pack = account.getStickerStore().getStickerPack(stickerPackId);
if (pack != null) {
try {
context.getStickerHelper().retrieveStickerPack(stickerPackId, pack.packKey());
} catch (InvalidMessageException e) {
logger.warn("Failed to download sticker pack");
}
}
}
if (streamDetails == null) {
throw new FileNotFoundException();
}
return streamDetails.getStream();
}
@Override
public void close() {
Thread thread;
synchronized (messageHandlers) {
weakHandlers.clear();
messageHandlers.clear();
thread = receiveThread;
receiveThread = null;
}
if (thread != null) {
stopReceiveThread(thread);
}
context.close();
executor.close();
dependencies.getAuthenticatedSignalWebSocket().disconnect();
dependencies.getUnauthenticatedSignalWebSocket().disconnect();
dependencies.getPushServiceSocket().close();
disposable.dispose();
if (account != null) {
account.close();
}
synchronized (closedListeners) {
closedListeners.forEach(Runnable::run);
closedListeners.clear();
}
account = null;
}
}