mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-23 06:39:04 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29dcac23cf | ||
|
|
dc2a74e824 | ||
|
|
dac64f773d | ||
|
|
926cd256e6 | ||
|
|
3d7109eb01 | ||
|
|
d0724fb5b5 | ||
|
|
60649041c8 | ||
|
|
e1d7bc5587 | ||
|
|
c146224493 | ||
|
|
a255a7fecf | ||
|
|
752f63a5e6 | ||
|
|
52a0f6415e |
@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded
|
||||
|
||||
## [0.14.8] - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
@ -5,6 +5,7 @@ It supports registering, verifying, sending and receiving messages.
|
||||
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
|
||||
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service).
|
||||
For registering you need a phone number where you can receive SMS or incoming calls.
|
||||
Existing accounts without a phone number can be linked from a compatible Signal mobile app.
|
||||
|
||||
signal-cli is primarily intended to be used on servers to notify admins of important events.
|
||||
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
|
||||
@ -60,14 +61,16 @@ For a complete usage overview please read
|
||||
the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and
|
||||
the [wiki](https://github.com/AsamK/signal-cli/wiki).
|
||||
|
||||
Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it
|
||||
For a numbered account, ACCOUNT is your phone number in international format and must include the country calling code. Hence it
|
||||
should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list
|
||||
of all country codes.)
|
||||
For a linked account without a phone number, use its ACI (Account ID) instead.
|
||||
See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
|
||||
|
||||
* Link to an existing account
|
||||
|
||||
If you have an existing Signal account associated
|
||||
with a number, you can link signal-cli to it with:
|
||||
If you have an existing Signal account, with or without a phone number,
|
||||
you can link signal-cli to it with:
|
||||
|
||||
signal-cli link
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ plugins {
|
||||
|
||||
allprojects {
|
||||
group = "org.asamk"
|
||||
version = "0.14.8"
|
||||
version = "0.14.9-SNAPSHOT"
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
@ -98,6 +98,14 @@ public interface Manager extends Closeable {
|
||||
|
||||
String getSelfACI();
|
||||
|
||||
/**
|
||||
* Returns the phone number for numbered accounts, or the ACI for numberless accounts.
|
||||
*/
|
||||
default String getSelfIdentifier() {
|
||||
final var number = getSelfNumber();
|
||||
return number != null ? number : getSelfACI();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used for checking a set of phone numbers for registration on Signal
|
||||
*
|
||||
|
||||
@ -8,8 +8,6 @@ import java.util.function.Consumer;
|
||||
|
||||
public interface MultiAccountManager extends AutoCloseable {
|
||||
|
||||
List<String> getAccountNumbers();
|
||||
|
||||
List<Manager> getManagers();
|
||||
|
||||
void addOnManagerAddedHandler(Consumer<Manager> handler);
|
||||
|
||||
@ -10,5 +10,8 @@ public interface ProvisioningManager {
|
||||
|
||||
URI getDeviceLinkUri() throws TimeoutException, IOException;
|
||||
|
||||
/**
|
||||
* Completes linking and returns the account's phone number, or ACI if it has no number.
|
||||
*/
|
||||
String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
|
||||
import java.io.Closeable;
|
||||
@ -17,13 +18,19 @@ public interface RegistrationManager extends Closeable {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException;
|
||||
|
||||
void verifyAccount(
|
||||
String verificationCode,
|
||||
String pin
|
||||
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
|
||||
|
||||
void registerWithRecoveryKey(
|
||||
String recoveryKey,
|
||||
boolean forceRegister,
|
||||
Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException;
|
||||
|
||||
void deleteLocalAccountData() throws IOException;
|
||||
|
||||
boolean isRegistered();
|
||||
|
||||
@ -25,6 +25,7 @@ import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SignalAccountFiles {
|
||||
|
||||
@ -65,15 +66,26 @@ public class SignalAccountFiles {
|
||||
return accountsStore.getAllNumbers();
|
||||
}
|
||||
|
||||
public Set<String> getAllLocalAccountIdentifiers() throws IOException {
|
||||
return accountsStore.getAllAccounts()
|
||||
.stream()
|
||||
.map(a -> a.number() != null ? a.number() : a.uuid())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public MultiAccountManager initMultiAccountManager() throws IOException {
|
||||
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
|
||||
final var identifier = a.number() != null ? a.number() : a.uuid();
|
||||
try {
|
||||
return new Pair<Manager, Throwable>(initManagerByNumber(a.number(), a.path()), null);
|
||||
final var manager = a.number() != null
|
||||
? initManagerByNumber(a.number(), a.path())
|
||||
: initManagerByAci(ACI.parseOrThrow(a.uuid()), a.path());
|
||||
return new Pair<Manager, Throwable>(manager, null);
|
||||
} catch (NotRegisteredException e) {
|
||||
logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
|
||||
logger.warn("Ignoring {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
|
||||
return null;
|
||||
} catch (AccountCheckException | IOException e) {
|
||||
logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName());
|
||||
logger.error("Failed to load {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName());
|
||||
return new Pair<Manager, Throwable>(null, e);
|
||||
}
|
||||
}).filter(Objects::nonNull).toList();
|
||||
@ -199,6 +211,11 @@ public class SignalAccountFiles {
|
||||
String number,
|
||||
Consumer<Manager> newManagerListener
|
||||
) throws IOException {
|
||||
final var aci = ACI.parseOrNull(number);
|
||||
if (aci != null) {
|
||||
return initRegistrationManager(aci, newManagerListener);
|
||||
}
|
||||
|
||||
final var accountPath = accountsStore.getPathByNumber(number);
|
||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
|
||||
@ -238,4 +255,43 @@ public class SignalAccountFiles {
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
||||
}
|
||||
|
||||
private RegistrationManager initRegistrationManager(
|
||||
final ACI aci,
|
||||
final Consumer<Manager> newManagerListener
|
||||
) throws IOException {
|
||||
final var accountPath = accountsStore.getPathByAci(aci);
|
||||
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
|
||||
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
|
||||
final var account = SignalAccount.create(pathConfig.dataPath(),
|
||||
newAccountPath,
|
||||
null,
|
||||
aci,
|
||||
serviceEnvironment,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
settings);
|
||||
account.initDatabase();
|
||||
return new RegistrationManagerImpl(account,
|
||||
pathConfig,
|
||||
serviceEnvironmentConfig,
|
||||
userAgent,
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
|
||||
}
|
||||
|
||||
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
|
||||
if (!aci.equals(account.getAci())) {
|
||||
account.close();
|
||||
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
|
||||
}
|
||||
account.initDatabase();
|
||||
return new RegistrationManagerImpl(account,
|
||||
pathConfig,
|
||||
serviceEnvironmentConfig,
|
||||
userAgent,
|
||||
newManagerListener,
|
||||
new AccountFileUpdaterImpl(accountsStore, accountPath));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
import org.signal.libsignal.net.BadRequestError;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class BadRequestException extends IOException {
|
||||
|
||||
private final BadRequestError error;
|
||||
|
||||
public BadRequestException(final BadRequestError error) {
|
||||
super(error.toString());
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public BadRequestError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,8 @@ import java.util.Optional;
|
||||
public record Message(
|
||||
String messageText,
|
||||
List<String> attachments,
|
||||
List<AttachmentDimensions> attachmentDimensions,
|
||||
List<String> attachmentBlurHashes,
|
||||
boolean viewOnce,
|
||||
boolean voiceNote,
|
||||
List<Mention> mentions,
|
||||
@ -17,6 +19,8 @@ public record Message(
|
||||
boolean urgent
|
||||
) {
|
||||
|
||||
public record AttachmentDimensions(int width, int height) {}
|
||||
|
||||
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
|
||||
|
||||
public record Quote(
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TotpRequiredException extends IOException {
|
||||
|
||||
public TotpRequiredException() {
|
||||
super("A TOTP token is required or the supplied token is incorrect");
|
||||
}
|
||||
}
|
||||
@ -28,11 +28,14 @@ public class ServiceConfig {
|
||||
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
|
||||
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
|
||||
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
|
||||
public static AccountAttributes.Capabilities getCapabilities(
|
||||
final boolean isPrimaryDevice,
|
||||
final boolean hasPhoneNumber
|
||||
) {
|
||||
final var attachmentBackfill = !isPrimaryDevice;
|
||||
final var spqr = true;
|
||||
final var usernameSyncChangeMessage = !isPrimaryDevice;
|
||||
final var optionalPhoneNumber = !isPrimaryDevice;
|
||||
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber;
|
||||
return new AccountAttributes.Capabilities(true,
|
||||
true,
|
||||
attachmentBackfill,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.api.BadRequestException;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.DeviceLinkUrl;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
@ -27,6 +28,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.network.api.AccountApiV2.SetAccountAttributesError;
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -96,10 +98,10 @@ public class AccountHelper {
|
||||
} else {
|
||||
context.getPreKeyHelper().refreshPreKeysIfNecessary();
|
||||
}
|
||||
if (account.getPni() == null) {
|
||||
if (account.getPni() == null && account.getNumber() != null) {
|
||||
checkWhoAmiI();
|
||||
}
|
||||
if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
||||
if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) {
|
||||
throw new IOException("Missing PNI identity key, relinking required");
|
||||
}
|
||||
if (account.getPreviousStorageVersion() < 10
|
||||
@ -138,8 +140,9 @@ public class AccountHelper {
|
||||
final var whoAmI = dependencies.getAccountManager().getWhoAmI();
|
||||
final var number = whoAmI.getNumber();
|
||||
final var aci = ACI.parseOrThrow(whoAmI.getAci());
|
||||
final var pni = PNI.parseOrThrow(whoAmI.getPni());
|
||||
if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) {
|
||||
final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni());
|
||||
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni,
|
||||
account.getPni())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -150,7 +153,7 @@ public class AccountHelper {
|
||||
account.setNumber(number);
|
||||
account.setAci(aci);
|
||||
account.setPni(pni);
|
||||
if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
||||
if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
|
||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
|
||||
@ -564,7 +567,20 @@ public class AccountHelper {
|
||||
}
|
||||
|
||||
public void updateAccountAttributes() throws IOException {
|
||||
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
|
||||
if (account.getNumber() != null) {
|
||||
handleResponseException(dependencies.getAccountApi()
|
||||
.setAccountAttributes(account.getAccountAttributes(null)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
|
||||
.setAccountAttributes(account.getAccountAttributesV2(), cont));
|
||||
} catch (BadRequestException e) {
|
||||
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
|
||||
throw new AuthorizationFailedException(401, "Authorization failed!");
|
||||
}
|
||||
throw new IOException("Account attribute update rate limited; try again later");
|
||||
}
|
||||
}
|
||||
|
||||
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.api.AttachmentInvalidException;
|
||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.internal.SignalDependencies;
|
||||
import org.asamk.signal.manager.storage.AttachmentStore;
|
||||
@ -52,9 +53,11 @@ public class AttachmentHelper {
|
||||
|
||||
public List<SignalServiceAttachment> uploadAttachments(
|
||||
final List<String> attachments,
|
||||
final List<AttachmentDimensions> dimensions,
|
||||
final List<String> blurHashes,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
|
||||
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote);
|
||||
|
||||
try {
|
||||
// Upload attachments here, so we only upload once even for multiple recipients
|
||||
@ -71,26 +74,31 @@ public class AttachmentHelper {
|
||||
}
|
||||
|
||||
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
|
||||
return uploadAttachments(attachments, false);
|
||||
return uploadAttachments(attachments, List.of(), List.of(), false);
|
||||
}
|
||||
|
||||
private List<SignalServiceAttachmentStream> createAttachmentStreams(
|
||||
List<String> attachments,
|
||||
List<AttachmentDimensions> dimensions,
|
||||
List<String> blurHashes,
|
||||
boolean voiceNote
|
||||
) throws AttachmentInvalidException, IOException {
|
||||
if (attachments == null) {
|
||||
return null;
|
||||
}
|
||||
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
|
||||
for (var attachment : attachments) {
|
||||
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
|
||||
signalServiceAttachments.add(attachmentStream);
|
||||
for (var i = 0; i < attachments.size(); i++) {
|
||||
final var size = i < dimensions.size() ? dimensions.get(i) : null;
|
||||
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null;
|
||||
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, blurHash, voiceNote));
|
||||
}
|
||||
return signalServiceAttachments;
|
||||
}
|
||||
|
||||
private SignalServiceAttachmentStream getAttachmentStream(
|
||||
final String attachment,
|
||||
final AttachmentDimensions dimensions,
|
||||
final String blurHash,
|
||||
final boolean voiceNote
|
||||
) throws AttachmentInvalidException {
|
||||
try {
|
||||
@ -116,6 +124,8 @@ public class AttachmentHelper {
|
||||
return AttachmentUtils.createAttachmentStream(streamDetails,
|
||||
streamDetailsAndFileName.second(),
|
||||
voiceNote,
|
||||
dimensions,
|
||||
blurHash,
|
||||
uploadSpec);
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
@ -130,7 +140,7 @@ public class AttachmentHelper {
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
|
||||
final var attachmentStream = getAttachmentStream(attachment, false);
|
||||
final var attachmentStream = getAttachmentStream(attachment, null, null, false);
|
||||
return uploadAttachment(attachmentStream);
|
||||
}
|
||||
|
||||
@ -158,7 +168,7 @@ public class AttachmentHelper {
|
||||
try {
|
||||
attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream));
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage());
|
||||
logger.warn("Failed to download attachment ({}), ignoring", pointer.getRemoteId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -136,7 +136,7 @@ class GroupV2Helper {
|
||||
|
||||
int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
|
||||
ByteString aciBytes = getSelfAci().toByteString();
|
||||
ByteString pniBytes = getSelfPni().toByteString();
|
||||
ByteString pniBytes = getSelfPni() == null ? null : getSelfPni().toByteString();
|
||||
for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
|
||||
if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
|
||||
return decryptedMember.joinedAtRevision;
|
||||
@ -264,7 +264,7 @@ class GroupV2Helper {
|
||||
var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
|
||||
final var selfAci = getSelfAci();
|
||||
var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
|
||||
if (selfPendingMember.isEmpty()) {
|
||||
if (selfPendingMember.isEmpty() && getSelfPni() != null) {
|
||||
selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni());
|
||||
}
|
||||
|
||||
|
||||
@ -339,7 +339,7 @@ public final class IncomingMessageHandler {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
if (account.getPni().equals(destination.serviceId)) {
|
||||
if (destination.serviceId.equals(account.getPni())) {
|
||||
account.getRecipientStore().markNeedsPniSignature(sender, true);
|
||||
} else if (account.getAci().equals(destination.serviceId)) {
|
||||
account.getRecipientStore().markNeedsPniSignature(sender, false);
|
||||
@ -1124,7 +1124,7 @@ public final class IncomingMessageHandler {
|
||||
if (destination == null || destination.isUnknown()) {
|
||||
throw new InvalidMessageException("Missing destination");
|
||||
}
|
||||
if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
|
||||
if (!destination.equals(account.getAci()) && !destination.equals(account.getPni())) {
|
||||
throw new InvalidMessageException("Message not intended for this account");
|
||||
}
|
||||
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
|
||||
|
||||
@ -886,7 +886,8 @@ public class SendHelper {
|
||||
|
||||
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
|
||||
try {
|
||||
final boolean includePniSignature = account.getRecipientStore().needsPniSignature(recipientId);
|
||||
final boolean includePniSignature = account.getPni() != null && account.getRecipientStore()
|
||||
.needsPniSignature(recipientId);
|
||||
try {
|
||||
return s.send(messageSender,
|
||||
address,
|
||||
|
||||
@ -970,7 +970,10 @@ public class ManagerImpl implements Manager {
|
||||
}
|
||||
if (!message.attachments().isEmpty()) {
|
||||
final var uploadedAttachments = context.getAttachmentHelper()
|
||||
.uploadAttachments(message.attachments(), message.voiceNote());
|
||||
.uploadAttachments(message.attachments(),
|
||||
message.attachmentDimensions(),
|
||||
message.attachmentBlurHashes(),
|
||||
message.voiceNote());
|
||||
if (!additionalAttachments.isEmpty()) {
|
||||
additionalAttachments.addAll(uploadedAttachments);
|
||||
messageBuilder.withAttachments(additionalAttachments);
|
||||
|
||||
@ -39,13 +39,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
||||
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAccountNumbers() {
|
||||
synchronized (managers) {
|
||||
return managers.stream().map(Manager::getSelfNumber).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Manager> getManagers() {
|
||||
synchronized (managers) {
|
||||
@ -121,7 +114,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
|
||||
} else {
|
||||
// Phone number — check already loaded managers
|
||||
var existing = managers.stream()
|
||||
.filter(m -> m.getSelfNumber().equals(identifier))
|
||||
.filter(m -> identifier.equals(m.getSelfNumber()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
|
||||
@ -19,8 +19,8 @@ package org.asamk.signal.manager.internal;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.ProvisioningManager;
|
||||
import org.asamk.signal.manager.Settings;
|
||||
import org.asamk.signal.manager.api.BadRequestException;
|
||||
import org.asamk.signal.manager.api.UserAlreadyExistsException;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.storage.accounts.AccountsStore;
|
||||
@ -34,13 +34,15 @@ import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.network.api.RegistrationApiV2;
|
||||
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
|
||||
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
|
||||
import org.signal.network.rest.SignalRestClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.account.DeviceAttributes;
|
||||
import org.whispersystems.signalservice.api.account.PreKeyCollection;
|
||||
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
||||
@ -67,7 +69,7 @@ import kotlinx.coroutines.BuildersKt;
|
||||
import kotlinx.coroutines.CoroutineScope;
|
||||
|
||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
||||
|
||||
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
|
||||
@ -114,7 +116,8 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
public URI getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
try {
|
||||
var url = urlFuture.get(30, TimeUnit.SECONDS);
|
||||
return new URI(url);
|
||||
// Mode.Link(false) does not advertise any capabilities itself.
|
||||
return new URI(url + "&capabilities=nopni");
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
throw new TimeoutException("Timed out waiting for provisioning URL");
|
||||
} catch (InterruptedException e) {
|
||||
@ -148,18 +151,20 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
|
||||
var number = msg.number;
|
||||
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
|
||||
var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary);
|
||||
var pni = parsePni(msg);
|
||||
var identifier = number != null ? number : aci.toString();
|
||||
|
||||
logger.info("Received link information from {}, linking in progress ...", number);
|
||||
logger.info("Received link information from {}, linking in progress ...", identifier);
|
||||
|
||||
var accountPath = accountsStore.getPathByAci(aci);
|
||||
if (accountPath == null) {
|
||||
if (accountPath == null && number != null) {
|
||||
accountPath = accountsStore.getPathByNumber(number);
|
||||
}
|
||||
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
|
||||
accountPath);
|
||||
if (accountExists && !canRelinkExistingAccount(accountPath)) {
|
||||
throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
|
||||
throw new UserAlreadyExistsException(identifier,
|
||||
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
|
||||
}
|
||||
if (accountPath == null) {
|
||||
accountPath = accountsStore.addAccount(number, aci);
|
||||
@ -173,8 +178,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
try {
|
||||
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
|
||||
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
|
||||
pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
|
||||
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
|
||||
pniIdentity = pni == null
|
||||
? null
|
||||
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
|
||||
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
|
||||
profileKey = msg.profileKey == null
|
||||
? KeyUtils.createProfileKey()
|
||||
: new ProfileKey(msg.profileKey.toByteArray());
|
||||
@ -222,28 +229,19 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
}
|
||||
|
||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
final var pniPreKeys = pni == null
|
||||
? null
|
||||
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
|
||||
logger.debug("Finishing new device registration");
|
||||
final var attrs = account.getAccountAttributes(null);
|
||||
final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
|
||||
attrs.getRegistrationId(),
|
||||
attrs.getPniRegistrationId(),
|
||||
attrs.getName(),
|
||||
attrs.getCapabilities());
|
||||
final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials(
|
||||
serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||
null,
|
||||
null,
|
||||
number,
|
||||
SignalServiceAddress.DEFAULT_DEVICE_ID,
|
||||
password,
|
||||
userAgent,
|
||||
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
|
||||
ServiceConfig.GROUP_MAX_SIZE);
|
||||
final var registerResponse = handleResponseException(unauthAccountManager.getRegistrationApi()
|
||||
.registerAsSecondaryDevice(msg.provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, null));
|
||||
final var deviceId = Integer.parseInt(registerResponse.getDeviceId());
|
||||
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
|
||||
userAgent);
|
||||
final var registrationApi = new RegistrationApiV2(restClient, false);
|
||||
final var deviceId = registerLinkedDevice(registrationApi,
|
||||
account,
|
||||
msg.provisioningCode,
|
||||
aciPreKeys,
|
||||
pniPreKeys);
|
||||
|
||||
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
|
||||
linkingFinished = true;
|
||||
@ -277,7 +275,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
newManagerListener.accept(m);
|
||||
m = null;
|
||||
}
|
||||
return number;
|
||||
return identifier;
|
||||
} finally {
|
||||
if (m != null) {
|
||||
m.close();
|
||||
@ -296,6 +294,71 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
static PNI parsePni(final ProvisionMessage message) throws IOException {
|
||||
if (message.number != null) {
|
||||
return PNI.parseOrThrow(message.pni, message.pniBinary);
|
||||
}
|
||||
if (message.pni != null
|
||||
|| message.pniBinary != null
|
||||
|| message.pniIdentityKeyPublic != null
|
||||
|| message.pniIdentityKeyPrivate != null) {
|
||||
throw new IOException("Provisioning message has PNI material without a phone number");
|
||||
}
|
||||
if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) {
|
||||
throw new IOException("Numberless provisioning message is missing the group auth credential salt");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int registerLinkedDevice(
|
||||
final RegistrationApiV2 registrationApi,
|
||||
final SignalAccount account,
|
||||
final String provisioningCode,
|
||||
final PreKeyCollection aciPreKeys,
|
||||
final PreKeyCollection pniPreKeys
|
||||
) throws IOException {
|
||||
final var attrs = account.getAccountAttributesV2();
|
||||
final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(),
|
||||
attrs.getRegistrationId(),
|
||||
attrs.getPniRegistrationId(),
|
||||
attrs.getName(),
|
||||
attrs.getCapabilities());
|
||||
try {
|
||||
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
|
||||
account.getAci(),
|
||||
account.getPassword(),
|
||||
provisioningCode,
|
||||
deviceAttributes,
|
||||
toRegistrationPreKeys(aciPreKeys),
|
||||
toRegistrationPreKeys(pniPreKeys),
|
||||
null,
|
||||
cont));
|
||||
return result.getDeviceId();
|
||||
} catch (BadRequestException e) {
|
||||
throw switch (e.getError()) {
|
||||
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
|
||||
new AuthorizationFailedException(403, "Device verification failed");
|
||||
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
|
||||
new IOException("Linked device is missing a required account capability");
|
||||
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
|
||||
new IOException("Account has reached its linked device limit");
|
||||
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
|
||||
new IOException("Signal rejected the device linking request");
|
||||
case RegisterAsLinkedDeviceError.RateLimited ignored ->
|
||||
new IOException("Device linking rate limited; try again later");
|
||||
default -> new IOException("Unexpected device linking response");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
|
||||
return preKeys == null
|
||||
? null
|
||||
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
|
||||
preKeys.getSignedPreKey(),
|
||||
preKeys.getLastResortKyberPreKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
socketHandle.close();
|
||||
|
||||
@ -18,12 +18,14 @@ package org.asamk.signal.manager.internal;
|
||||
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.BadRequestException;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.PinLockMissingException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.UpdateProfile;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
@ -33,10 +35,14 @@ import org.asamk.signal.manager.helper.PinHelper;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.asamk.signal.manager.util.NumberVerificationUtils;
|
||||
import org.signal.core.models.AccountEntropyPool;
|
||||
import org.signal.core.models.MasterKey;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.network.api.RegistrationApiV2;
|
||||
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
|
||||
import org.signal.network.rest.SignalRestClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
@ -50,15 +56,25 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
|
||||
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseException;
|
||||
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
|
||||
|
||||
public class RegistrationManagerImpl implements RegistrationManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
|
||||
|
||||
private static final class RecoveryRequestFailedException extends IOException {
|
||||
|
||||
private RecoveryRequestFailedException(final String message, final Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private SignalAccount account;
|
||||
private final PathConfig pathConfig;
|
||||
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
|
||||
@ -107,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
boolean voiceVerification,
|
||||
String captcha,
|
||||
final boolean forceRegister
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
|
||||
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException {
|
||||
if (account.isRegistered()
|
||||
&& account.getServiceEnvironment() != null
|
||||
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
|
||||
@ -129,6 +145,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
|
||||
return;
|
||||
}
|
||||
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.getNumber() == null) {
|
||||
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
|
||||
}
|
||||
|
||||
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
|
||||
logger.trace("Creating verification session");
|
||||
@ -184,6 +207,108 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWithRecoveryKey(
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
if (account.getAci() == null) {
|
||||
throw new IOException("Recovery-key registration requires an ACI account identifier");
|
||||
}
|
||||
if (account.isRegistered() && !forceRegister) {
|
||||
throw new IOException("Account is already registered; use --reregister to register it again");
|
||||
}
|
||||
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
|
||||
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
|
||||
throw new IOException("Invalid recovery key");
|
||||
}
|
||||
recoverAccount(totp, false, accountEntropyPool);
|
||||
}
|
||||
|
||||
private boolean attemptRecoverAccount(
|
||||
final Integer totp
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
final var accountEntropyPool = account.getAccountEntropyPool();
|
||||
try {
|
||||
recoverAccount(totp, false, accountEntropyPool);
|
||||
logger.info("Reregistered existing account using its ACI and Account Entropy Pool.");
|
||||
return true;
|
||||
} catch (TotpRequiredException | RateLimitException e) {
|
||||
throw e;
|
||||
} catch (RecoveryRequestFailedException e) {
|
||||
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void recoverAccount(
|
||||
final Integer totp,
|
||||
final boolean includeRegistrationLock,
|
||||
final AccountEntropyPool accountEntropyPool
|
||||
) throws IOException, RateLimitException, TotpRequiredException {
|
||||
if (account.getPniIdentityKeyPair() == null) {
|
||||
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
|
||||
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
final var masterKey = accountEntropyPool.deriveMasterKey();
|
||||
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
|
||||
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
|
||||
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
|
||||
final var registrationApi = new RegistrationApiV2(restClient, true);
|
||||
|
||||
final RegistrationApiV2.RegisterAccountResponse response;
|
||||
try {
|
||||
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
|
||||
account.getPassword(),
|
||||
null,
|
||||
recoveryPassword,
|
||||
null,
|
||||
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
|
||||
toRegistrationPreKeys(aciPreKeys),
|
||||
toRegistrationPreKeys(pniPreKeys),
|
||||
null,
|
||||
true,
|
||||
account.getAci(),
|
||||
totp,
|
||||
cont));
|
||||
} catch (BadRequestException e) {
|
||||
switch (e.getError()) {
|
||||
case RegisterAccountError.RegistrationLock ignored -> {
|
||||
if (includeRegistrationLock) {
|
||||
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
|
||||
}
|
||||
recoverAccount(totp, true, accountEntropyPool);
|
||||
return;
|
||||
}
|
||||
case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException();
|
||||
case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored ->
|
||||
throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e);
|
||||
case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null);
|
||||
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
|
||||
throw new IOException("signal-cli is too old to register this account", e);
|
||||
default -> throw new IOException("Signal rejected recovery-key registration", e);
|
||||
}
|
||||
}
|
||||
|
||||
final var aci = ACI.parseOrThrow(response.getAci());
|
||||
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
|
||||
final var authCredentialSalt = response.getAuthCredentialSalt() == null
|
||||
? null
|
||||
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
|
||||
account.finishRecoveryRegistration(aci,
|
||||
pni,
|
||||
response.getE164(),
|
||||
accountEntropyPool,
|
||||
authCredentialSalt,
|
||||
aciPreKeys,
|
||||
pni == null ? null : pniPreKeys);
|
||||
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
|
||||
finishManagerRegistration(response.getStorageCapable());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteLocalAccountData() throws IOException {
|
||||
account.deleteAccountData();
|
||||
@ -291,13 +416,17 @@ public class RegistrationManagerImpl implements RegistrationManager {
|
||||
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
|
||||
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
|
||||
|
||||
finishManagerRegistration(response.isStorageCapable());
|
||||
}
|
||||
|
||||
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
|
||||
ManagerImpl m = null;
|
||||
try {
|
||||
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
|
||||
account = null;
|
||||
|
||||
m.refreshPreKeys();
|
||||
if (response.isStorageCapable()) {
|
||||
if (storageCapable) {
|
||||
m.syncRemoteStorage();
|
||||
}
|
||||
// Set an initial empty profile so user can be added to groups
|
||||
|
||||
@ -8,6 +8,7 @@ import org.signal.libsignal.metadata.certificate.CertificateValidator;
|
||||
import org.signal.libsignal.net.Network;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.network.api.AccountApiV2;
|
||||
import org.signal.network.api.AttachmentApi;
|
||||
import org.signal.network.api.CallingApi;
|
||||
import org.signal.network.api.CdsApi;
|
||||
@ -73,6 +74,7 @@ public class SignalDependencies {
|
||||
|
||||
private SignalServiceAccountManager accountManager;
|
||||
private AccountApi accountApi;
|
||||
private AccountApiV2 accountApiV2;
|
||||
private RateLimitChallengeApi rateLimitChallengeApi;
|
||||
private CdsApi cdsApi;
|
||||
private UsernameApi usernameApi;
|
||||
@ -221,6 +223,11 @@ public class SignalDependencies {
|
||||
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
|
||||
}
|
||||
|
||||
public AccountApiV2 getAccountApiV2() {
|
||||
return getOrCreate(() -> accountApiV2,
|
||||
() -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket()));
|
||||
}
|
||||
|
||||
public RateLimitChallengeApi getRateLimitChallengeApi() {
|
||||
return getOrCreate(() -> rateLimitChallengeApi,
|
||||
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));
|
||||
|
||||
@ -8,9 +8,11 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Optional;
|
||||
|
||||
public class AttachmentStore {
|
||||
@ -51,8 +53,23 @@ public class AttachmentStore {
|
||||
|
||||
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
|
||||
createAttachmentsDir();
|
||||
try (OutputStream output = new FileOutputStream(attachmentFile)) {
|
||||
storer.store(output);
|
||||
final var temporaryFile = Files.createTempFile(attachmentsPath.toPath(),
|
||||
attachmentFile.getName() + ".",
|
||||
".tmp");
|
||||
try {
|
||||
try (OutputStream output = Files.newOutputStream(temporaryFile)) {
|
||||
storer.store(output);
|
||||
}
|
||||
try {
|
||||
Files.move(temporaryFile,
|
||||
attachmentFile.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(temporaryFile, attachmentFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -71,6 +71,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.network.api.RegistrationApiV2;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
|
||||
@ -233,6 +234,28 @@ public class SignalAccount implements Closeable {
|
||||
IdentityKeyPair pniIdentityKey,
|
||||
ProfileKey profileKey,
|
||||
final Settings settings
|
||||
) throws IOException {
|
||||
return create(dataPath,
|
||||
accountPath,
|
||||
number,
|
||||
null,
|
||||
serviceEnvironment,
|
||||
aciIdentityKey,
|
||||
pniIdentityKey,
|
||||
profileKey,
|
||||
settings);
|
||||
}
|
||||
|
||||
public static SignalAccount create(
|
||||
File dataPath,
|
||||
String accountPath,
|
||||
String number,
|
||||
ACI aci,
|
||||
ServiceEnvironment serviceEnvironment,
|
||||
IdentityKeyPair aciIdentityKey,
|
||||
IdentityKeyPair pniIdentityKey,
|
||||
ProfileKey profileKey,
|
||||
final Settings settings
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
var fileName = getFileName(dataPath, accountPath);
|
||||
@ -251,6 +274,7 @@ public class SignalAccount implements Closeable {
|
||||
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
|
||||
signalAccount.dataPath = dataPath;
|
||||
signalAccount.aciAccountData.setServiceId(aci);
|
||||
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
|
||||
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
|
||||
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
|
||||
@ -350,7 +374,9 @@ public class SignalAccount implements Closeable {
|
||||
this.registered = true;
|
||||
this.deviceId = deviceId;
|
||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
||||
if (pniPreKeys != null) {
|
||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
@ -392,6 +418,53 @@ public class SignalAccount implements Closeable {
|
||||
clearSessionId();
|
||||
}
|
||||
|
||||
public void finishRecoveryRegistration(
|
||||
final ACI aci,
|
||||
final PNI pni,
|
||||
final String number,
|
||||
final AccountEntropyPool accountEntropyPool,
|
||||
final byte[] authCredentialSalt,
|
||||
final PreKeyCollection aciPreKeys,
|
||||
final PreKeyCollection pniPreKeys
|
||||
) {
|
||||
this.pinMasterKey = null;
|
||||
this.accountEntropyPool = accountEntropyPool;
|
||||
this.authCredentialSalt = authCredentialSalt;
|
||||
this.number = number;
|
||||
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
|
||||
this.setStorageManifest(null);
|
||||
this.storageKey = null;
|
||||
this.encryptedDeviceName = null;
|
||||
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
this.isMultiDevice = false;
|
||||
this.registered = true;
|
||||
this.aciAccountData.setServiceId(aci);
|
||||
this.pniAccountData.setServiceId(pni);
|
||||
if (pni == null) {
|
||||
this.pniAccountData.setIdentityKeyPair(null);
|
||||
}
|
||||
init();
|
||||
this.registrationLockPin = null;
|
||||
setLastReceiveTimestamp(0L);
|
||||
setLastAppliedPniChangeServerTimestamp(0L);
|
||||
save();
|
||||
|
||||
setPreKeys(ServiceIdType.ACI, aciPreKeys);
|
||||
if (pni != null && pniPreKeys != null) {
|
||||
setPreKeys(ServiceIdType.PNI, pniPreKeys);
|
||||
}
|
||||
aciAccountData.getSessionStore().archiveAllSessions();
|
||||
pniAccountData.getSessionStore().archiveAllSessions();
|
||||
getSenderKeyStore().deleteAll();
|
||||
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
|
||||
trustSelfIdentity(ServiceIdType.ACI);
|
||||
if (pni != null) {
|
||||
trustSelfIdentity(ServiceIdType.PNI);
|
||||
}
|
||||
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
|
||||
clearSessionId();
|
||||
}
|
||||
|
||||
public void initDatabase() {
|
||||
getAccountDatabase();
|
||||
}
|
||||
@ -401,7 +474,7 @@ public class SignalAccount implements Closeable {
|
||||
}
|
||||
|
||||
private void migrateLegacyConfigs() {
|
||||
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
|
||||
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) {
|
||||
logger.trace("Migrating legacy parts of account file");
|
||||
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
@ -1423,7 +1496,56 @@ public class SignalAccount implements Closeable {
|
||||
}
|
||||
|
||||
public AccountAttributes.Capabilities getAccountCapabilities() {
|
||||
return getCapabilities(isPrimaryDevice());
|
||||
return getCapabilities(isPrimaryDevice(), number != null);
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
|
||||
return getAccountAttributesV2(false, getRegistrationLock());
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
||||
final boolean includePniRegistrationId,
|
||||
final String registrationLock
|
||||
) {
|
||||
return getAccountAttributesV2(includePniRegistrationId,
|
||||
registrationLock,
|
||||
getRecoveryPassword(),
|
||||
number == null ? null : isDiscoverableByPhoneNumber());
|
||||
}
|
||||
|
||||
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
|
||||
final String registrationLock,
|
||||
final String recoveryPassword
|
||||
) {
|
||||
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
|
||||
}
|
||||
|
||||
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
|
||||
final boolean includePniRegistrationId,
|
||||
final String registrationLock,
|
||||
final String recoveryPassword,
|
||||
final Boolean discoverableByPhoneNumber
|
||||
) {
|
||||
final var attributes = getAccountAttributes(null);
|
||||
final var capabilities = attributes.getCapabilities();
|
||||
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
|
||||
attributes.getRegistrationId(),
|
||||
attributes.getVoice(),
|
||||
attributes.getVideo(),
|
||||
attributes.getFetchesMessages(),
|
||||
registrationLock,
|
||||
attributes.getUnidentifiedAccessKey(),
|
||||
attributes.getUnrestrictedUnidentifiedAccess(),
|
||||
discoverableByPhoneNumber,
|
||||
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
|
||||
capabilities.getVersionedExpirationTimer(),
|
||||
capabilities.getAttachmentBackfill(),
|
||||
capabilities.getSpqr(),
|
||||
capabilities.getUsernameChangeSyncMessage(),
|
||||
capabilities.getOptionalPhoneNumber()),
|
||||
attributes.getName(),
|
||||
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
|
||||
recoveryPassword);
|
||||
}
|
||||
|
||||
public ServiceId getAccountId(ServiceIdType serviceIdType) {
|
||||
@ -1636,6 +1758,10 @@ public class SignalAccount implements Closeable {
|
||||
return accountEntropyPool;
|
||||
}
|
||||
|
||||
public AccountEntropyPool getAccountEntropyPool() {
|
||||
return accountEntropyPool;
|
||||
}
|
||||
|
||||
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
|
||||
this.accountEntropyPool = accountEntropyPool;
|
||||
if (accountEntropyPool != null) {
|
||||
|
||||
@ -63,11 +63,14 @@ public class AccountsStore {
|
||||
public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
|
||||
return readAccounts().stream()
|
||||
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
||||
.filter(a -> a.number() != null)
|
||||
.filter(a -> a.number() != null || a.uuid() != null)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public synchronized String getPathByNumber(String number) throws IOException {
|
||||
if (number == null) {
|
||||
return null;
|
||||
}
|
||||
return readAccounts().stream()
|
||||
.filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
|
||||
.filter(a -> number.equals(a.number()))
|
||||
@ -101,7 +104,7 @@ public class AccountsStore {
|
||||
if (number != null && number.equals(a.number())) {
|
||||
return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
|
||||
}
|
||||
if (aci != null && aci.toString().equals(a.toString())) {
|
||||
if (aci != null && aci.toString().equals(a.uuid())) {
|
||||
return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
|
||||
}
|
||||
|
||||
@ -188,7 +191,7 @@ public class AccountsStore {
|
||||
private List<AccountsStorage.Account> readAccounts() throws IOException {
|
||||
final var pair = openFileChannel(getAccountsFile());
|
||||
try (final var fileChannel = pair.first(); final var lock = pair.second()) {
|
||||
final var storage = readAccountsLocked(fileChannel);
|
||||
var storage = readAccountsLocked(fileChannel);
|
||||
|
||||
var accountsVersion = storage.version() == null ? 1 : storage.version();
|
||||
if (accountsVersion > CURRENT_STORAGE_VERSION) {
|
||||
@ -197,7 +200,7 @@ public class AccountsStore {
|
||||
throw new IOException("Accounts file was created by a no longer supported older version: "
|
||||
+ accountsVersion);
|
||||
} else if (accountsVersion < CURRENT_STORAGE_VERSION) {
|
||||
return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
|
||||
storage = upgradeAccountsFile(fileChannel, storage, accountsVersion);
|
||||
}
|
||||
return storage.accounts();
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
@ -27,16 +28,21 @@ public class AttachmentUtils {
|
||||
StreamDetails streamDetails,
|
||||
Optional<String> name,
|
||||
boolean voiceNote,
|
||||
AttachmentDimensions dimensions,
|
||||
String blurHash,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
final var uploadTimestamp = System.currentTimeMillis();
|
||||
final var probedStream = probeImageDimensions(streamDetails);
|
||||
final var probedStream = dimensions != null ? new ProbedStream(streamDetails.getStream(),
|
||||
dimensions.width(),
|
||||
dimensions.height()) : probeImageDimensions(streamDetails);
|
||||
return SignalServiceAttachmentStream.newStreamBuilder()
|
||||
.withStream(probedStream.inputStream())
|
||||
.withContentType(streamDetails.getContentType())
|
||||
.withLength(streamDetails.getLength())
|
||||
.withFileName(name.orElse(null))
|
||||
.withVoiceNote(voiceNote)
|
||||
.withBlurHash(blurHash)
|
||||
.withWidth(probedStream.width())
|
||||
.withHeight(probedStream.height())
|
||||
.withUploadTimestamp(uploadTimestamp)
|
||||
@ -50,7 +56,7 @@ public class AttachmentUtils {
|
||||
Optional<String> name,
|
||||
ResumableUploadSpec resumableUploadSpec
|
||||
) throws ResumeLocationInvalidException, IOException {
|
||||
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
|
||||
return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -42,6 +42,19 @@ public class PhoneNumberFormatter {
|
||||
throw new InvalidNumberException("No valid characters found.");
|
||||
}
|
||||
|
||||
if (localNumber == null) {
|
||||
if (!number.startsWith("+")) {
|
||||
throw new InvalidNumberException(
|
||||
"Use an international number including the country code for a numberless account.");
|
||||
}
|
||||
try {
|
||||
final var util = PhoneNumberUtil.getInstance();
|
||||
return util.format(util.parse(number, null), PhoneNumberFormat.E164);
|
||||
} catch (NumberParseException e) {
|
||||
throw new InvalidNumberException("Invalid international phone number.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
|
||||
PhoneNumber localNumberObject = util.parse(localNumber, null);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.BadRequestException;
|
||||
import org.asamk.signal.manager.api.Pair;
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.signal.libsignal.net.BadRequestError;
|
||||
@ -194,8 +195,8 @@ public class Utils {
|
||||
}
|
||||
} else if (result instanceof RequestResult.RetryableNetworkError e) {
|
||||
throw e.getNetworkError();
|
||||
} else if (result instanceof RequestResult.NonSuccess) {
|
||||
throw new AssertionError();
|
||||
} else if (result instanceof RequestResult.NonSuccess<?> e) {
|
||||
throw new BadRequestException(e.getError());
|
||||
}
|
||||
throw new IllegalStateException("Unexpected value: " + result);
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.Settings;
|
||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class NumberlessGroupTest {
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
||||
"account",
|
||||
ServiceEnvironment.STAGING,
|
||||
Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) {
|
||||
account.setProvisioningData(null,
|
||||
aci,
|
||||
null,
|
||||
"test-password",
|
||||
new byte[]{1},
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
null,
|
||||
KeyUtils.createProfileKey(),
|
||||
null,
|
||||
new byte[32],
|
||||
null);
|
||||
final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString())
|
||||
.joinedAtRevision(1)
|
||||
.build();
|
||||
final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString())
|
||||
.joinedAtRevision(4)
|
||||
.build();
|
||||
final var group = new DecryptedGroup.Builder().revision(9)
|
||||
.members(List.of(otherMember, selfMember))
|
||||
.build();
|
||||
assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group));
|
||||
assertEquals(9,
|
||||
context.getGroupV2Helper()
|
||||
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -39,8 +39,8 @@ class StorageHelperTest {
|
||||
Set.of(pendingId)));
|
||||
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(write(List.of()), Set.of(pendingId)));
|
||||
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(new WriteOperationResult(null,
|
||||
List.of(pendingRecord),
|
||||
List.of(new byte[]{3})), Set.of(pendingId)));
|
||||
List.of(pendingRecord),
|
||||
List.of(new byte[]{3})), Set.of(pendingId)));
|
||||
}
|
||||
|
||||
private static SignalStorageRecord record(final StorageId id) {
|
||||
|
||||
@ -0,0 +1,182 @@
|
||||
package org.asamk.signal.manager.internal;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.asamk.signal.manager.Settings;
|
||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.network.api.RegistrationApiV2;
|
||||
import org.signal.network.rest.SignalRestClient;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import okhttp3.Credentials;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Protocol;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okio.Buffer;
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class NumberlessProvisioningTest {
|
||||
|
||||
private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void numberlessProvisioningRequiresGroupCredentialSalt() {
|
||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
|
||||
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
|
||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
|
||||
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception {
|
||||
final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32]));
|
||||
assertNull(ProvisioningManagerImpl.parsePni(message.build()));
|
||||
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build()));
|
||||
assertEquals(PNI_ID,
|
||||
ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123")
|
||||
.pni(PNI_ID.toString())
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
|
||||
checkLinkRequest(null, null, 200, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
|
||||
checkLinkRequest("+12025550123", PNI_ID, 200, null);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"422, Signal rejected the device linking request",
|
||||
"403, [403] Device verification failed",
|
||||
"409, Linked device is missing a required account capability",
|
||||
"411, Account has reached its linked device limit",
|
||||
"429, Device linking rate limited; try again later"
|
||||
})
|
||||
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
|
||||
final int statusCode,
|
||||
final String errorMessage
|
||||
) throws Exception {
|
||||
checkLinkRequest(null, null, statusCode, errorMessage);
|
||||
}
|
||||
|
||||
private void checkLinkRequest(
|
||||
final String number,
|
||||
final PNI pni,
|
||||
final int statusCode,
|
||||
final String errorMessage
|
||||
) throws Exception {
|
||||
final var request = new AtomicReference<Request>();
|
||||
final var body = new AtomicReference<JsonNode>();
|
||||
final var mapper = new ObjectMapper();
|
||||
final var client = new OkHttpClient.Builder().addInterceptor(chain -> {
|
||||
request.set(chain.request());
|
||||
final var buffer = new Buffer();
|
||||
chain.request().body().writeTo(buffer);
|
||||
body.set(mapper.readTree(buffer.readUtf8()));
|
||||
return new Response.Builder().request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(statusCode)
|
||||
.message("Test response")
|
||||
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
|
||||
MediaType.get("application/json")))
|
||||
.build();
|
||||
}).build();
|
||||
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
|
||||
final var restClient = new SignalRestClient(config.signalServiceConfiguration(),
|
||||
"signal-cli-test",
|
||||
null,
|
||||
false,
|
||||
1000L,
|
||||
new SecureRandom(),
|
||||
client);
|
||||
final var api = new RegistrationApiV2(restClient, false);
|
||||
|
||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
||||
"account",
|
||||
ServiceEnvironment.STAGING,
|
||||
Settings.DEFAULT)) {
|
||||
account.setProvisioningData(number,
|
||||
ACI_ID,
|
||||
pni,
|
||||
"test-password",
|
||||
new byte[]{1},
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
pni == null ? null : KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
null,
|
||||
new byte[32],
|
||||
null);
|
||||
final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniKeys = pni == null
|
||||
? null
|
||||
: KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
if (statusCode == 200) {
|
||||
assertEquals(2,
|
||||
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
|
||||
} else {
|
||||
final var error = assertThrows(IOException.class,
|
||||
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
|
||||
account,
|
||||
"test-code",
|
||||
aciKeys,
|
||||
pniKeys));
|
||||
assertEquals(errorMessage, error.getMessage());
|
||||
if (statusCode == 403) {
|
||||
assertInstanceOf(AuthorizationFailedException.class, error);
|
||||
}
|
||||
}
|
||||
assertEquals("PUT", request.get().method());
|
||||
assertEquals("/v1/devices/link", request.get().url().encodedPath());
|
||||
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
|
||||
assertTrue(body.get()
|
||||
.path("accountAttributes")
|
||||
.path("capabilities")
|
||||
.path("optionalPhoneNumber")
|
||||
.asBoolean());
|
||||
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
|
||||
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
|
||||
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
|
||||
assertEquals(pni != null, body.get().has("pniPqLastResortPreKey"));
|
||||
assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId"));
|
||||
} finally {
|
||||
client.dispatcher().executorService().shutdown();
|
||||
client.connectionPool().evictAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package org.asamk.signal.manager.storage;
|
||||
|
||||
import org.asamk.signal.manager.Settings;
|
||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.signal.core.models.AccountEntropyPool;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class NumberlessAccountTest {
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void numberlessLinkedAccountSurvivesReload() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var identity = KeyUtils.generateIdentityKeyPair();
|
||||
final var salt = new byte[32];
|
||||
salt[0] = 42;
|
||||
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
|
||||
"account",
|
||||
ServiceEnvironment.STAGING,
|
||||
Settings.DEFAULT)) {
|
||||
account.setProvisioningData(null,
|
||||
aci,
|
||||
null,
|
||||
"test-password",
|
||||
new byte[]{1},
|
||||
identity,
|
||||
null,
|
||||
KeyUtils.createProfileKey(),
|
||||
null,
|
||||
salt,
|
||||
null);
|
||||
account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null);
|
||||
}
|
||||
|
||||
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
|
||||
assertTrue(account.isRegistered());
|
||||
assertFalse(account.isPrimaryDevice());
|
||||
assertEquals(2, account.getDeviceId());
|
||||
assertEquals(aci, account.getAci());
|
||||
assertNull(account.getNumber());
|
||||
assertNull(account.getPni());
|
||||
assertNull(account.getPniIdentityKeyPair());
|
||||
assertNull(account.getSignalServiceDataStore().pniOrNull());
|
||||
assertArrayEquals(identity.serialize(), account.getAciIdentityKeyPair().serialize());
|
||||
assertArrayEquals(salt, account.getAuthCredentialSalt());
|
||||
assertNull(account.getAccountAttributesV2().getPniRegistrationId());
|
||||
assertNull(account.getAccountAttributesV2().getDiscoverableByPhoneNumber());
|
||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var entropyPool = AccountEntropyPool.Companion.generate();
|
||||
final var salt = new byte[32];
|
||||
salt[0] = 42;
|
||||
try (final var account = SignalAccount.create(directory.toFile(),
|
||||
"account",
|
||||
null,
|
||||
aci,
|
||||
ServiceEnvironment.STAGING,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
Settings.DEFAULT)) {
|
||||
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
|
||||
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
|
||||
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
|
||||
}
|
||||
|
||||
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
|
||||
assertTrue(account.isRegistered());
|
||||
assertTrue(account.isPrimaryDevice());
|
||||
assertEquals(aci, account.getAci());
|
||||
assertNull(account.getNumber());
|
||||
assertNull(account.getPni());
|
||||
assertNull(account.getPniIdentityKeyPair());
|
||||
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
|
||||
assertArrayEquals(salt, account.getAuthCredentialSalt());
|
||||
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
try (final var account = SignalAccount.create(directory.toFile(),
|
||||
"account",
|
||||
"+12025550123",
|
||||
aci,
|
||||
ServiceEnvironment.STAGING,
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.generateIdentityKeyPair(),
|
||||
KeyUtils.createProfileKey(),
|
||||
Settings.DEFAULT)) {
|
||||
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
|
||||
.getDiscoverableByPhoneNumber());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package org.asamk.signal.manager.storage.accounts;
|
||||
|
||||
import org.asamk.signal.manager.api.ServiceEnvironment;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AccountsStoreTest {
|
||||
|
||||
private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception {
|
||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var path = store.addAccount(null, aci);
|
||||
store.addAccount("+12025550123", null);
|
||||
|
||||
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null);
|
||||
assertEquals(2, reopened.getAllAccounts().size());
|
||||
assertEquals(path, reopened.getPathByAci(aci));
|
||||
assertNull(reopened.getPathByNumber(null));
|
||||
assertEquals(Set.of("+12025550123"), reopened.getAllNumbers());
|
||||
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception {
|
||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
||||
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
|
||||
final var oldPath = store.addAccount(null, aci);
|
||||
Files.createFile(directory.resolve(oldPath));
|
||||
final var newPath = store.addAccount(null, null);
|
||||
store.updateAccount(newPath, null, aci);
|
||||
|
||||
assertEquals(newPath, store.getPathByAci(aci));
|
||||
assertEquals(1, store.getAllAccounts().size());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void replacingANumberKeepsTheOldAccountAvailableByAci(final boolean update) throws Exception {
|
||||
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
||||
final var oldPath = store.addAccount("+12025550123", OLD_ACI);
|
||||
Files.createFile(directory.resolve(oldPath));
|
||||
final String newPath;
|
||||
if (update) {
|
||||
newPath = store.addAccount("+12025550124", NEW_ACI);
|
||||
store.updateAccount(newPath, "+12025550123", NEW_ACI);
|
||||
} else {
|
||||
newPath = store.addAccount("+12025550123", NEW_ACI);
|
||||
}
|
||||
|
||||
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
|
||||
assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened));
|
||||
assertEquals(newPath, reopened.getPathByNumber("+12025550123"));
|
||||
assertEquals(oldPath, reopened.getPathByAci(OLD_ACI));
|
||||
}
|
||||
|
||||
private Set<String> getAccountPaths(final AccountsStore store) throws IOException {
|
||||
return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
@ -43,6 +44,42 @@ class AttachmentUtilsTest {
|
||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception {
|
||||
final var bytes = "opaque video bytes".getBytes();
|
||||
final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
|
||||
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
|
||||
final var attachment = AttachmentUtils.createAttachmentStream(details,
|
||||
Optional.of("clip.mp4"),
|
||||
false,
|
||||
new AttachmentDimensions(1080, 1920),
|
||||
blurHash,
|
||||
null);
|
||||
|
||||
assertEquals(1080, attachment.getWidth());
|
||||
assertEquals(1920, attachment.getHeight());
|
||||
assertEquals(Optional.of(blurHash), attachment.getBlurHash());
|
||||
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAttachmentStream_skipsProbingWhenDimensionsSupplied() throws Exception {
|
||||
final var imageBytes = pngBytes(37, 21);
|
||||
final var stream = new ByteArrayInputStream(imageBytes);
|
||||
final var details = new StreamDetails(stream, "image/png", imageBytes.length);
|
||||
final var attachment = AttachmentUtils.createAttachmentStream(details,
|
||||
Optional.of("meme.png"),
|
||||
false,
|
||||
new AttachmentDimensions(100, 200),
|
||||
null,
|
||||
null);
|
||||
|
||||
assertEquals(imageBytes.length, stream.available());
|
||||
assertEquals(100, attachment.getWidth());
|
||||
assertEquals(200, attachment.getHeight());
|
||||
assertArrayEquals(imageBytes, attachment.getInputStream().readAllBytes());
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(final int width, final int height) throws Exception {
|
||||
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
final var out = new ByteArrayOutputStream();
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.api.InvalidNumberException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class PhoneNumberFormatterTest {
|
||||
|
||||
@Test
|
||||
void numberlessAccountsCanAddressInternationalPhoneNumbers() throws Exception {
|
||||
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("+1 (202) 555-0123", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberlessAccountsCannotInferACountryCode() {
|
||||
assertThrows(InvalidNumberException.class, () -> PhoneNumberFormatter.formatNumber("2025550123", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberedAccountsStillInferTheirCountryCode() throws Exception {
|
||||
assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("2025550123", "+12025550124"));
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,13 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
|
||||
|
||||
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
|
||||
|
||||
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
|
||||
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/<account-id>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
|
||||
|
||||
Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenumber>`.
|
||||
Note: when daemon was started without explicit `-a ACCOUNT`, use the account's
|
||||
object path returned by `org.asamk.SignalControl.listAccounts` or
|
||||
`org.asamk.SignalControl.getAccount`. Numbered accounts use
|
||||
`/org/asamk/Signal/_<phonenumber>`. Accounts without a phone number use
|
||||
`/org/asamk/Signal/<ACI>`, with hyphens replaced by underscores.
|
||||
|
||||
== Description
|
||||
|
||||
@ -49,6 +53,11 @@ All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for be
|
||||
|
||||
Phone numbers always have the format +<countrycode><regional number>
|
||||
|
||||
For an account without a phone number, use its ACI (Account ID) with the
|
||||
`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts.
|
||||
An account without a phone number cannot infer the country code of a recipient;
|
||||
use a complete international phone number, an ACI, or a Signal username.
|
||||
|
||||
== Methods
|
||||
|
||||
=== SignalControl interface
|
||||
@ -73,11 +82,29 @@ 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
|
||||
----
|
||||
|
||||
listAccounts() -> accountList<as>::
|
||||
listAccounts() -> accountList<ao>::
|
||||
* accountList : Array of all attached accounts in DBus object path form
|
||||
|
||||
Exceptions: None
|
||||
|
||||
getAccount(identifier<s>) -> account<o>::
|
||||
* identifier : Phone number or ACI of an attached account
|
||||
* account : DBus object path of the account
|
||||
|
||||
Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown
|
||||
|
||||
startLink() -> deviceLinkUri<s>::
|
||||
* deviceLinkUri : Provisioning URI to scan with the primary Signal device
|
||||
|
||||
finishLink(deviceLinkUri<s>, newDeviceName<s>) -> identifier<s>::
|
||||
* deviceLinkUri : URI returned by startLink
|
||||
* newDeviceName : Name for the linked device
|
||||
* identifier : Phone number of the linked account, or its ACI when it has no phone number
|
||||
|
||||
Both linking methods support accounts with or without a phone number.
|
||||
|
||||
Exceptions: org.asamk.SignalControl.Error.Failure
|
||||
|
||||
register(number<s>, voiceVerification<b>) -> <>::
|
||||
* number : Phone number
|
||||
* voiceVerification : true = use voice verification; false = use SMS verification
|
||||
@ -115,9 +142,10 @@ Exceptions: None
|
||||
|
||||
The following methods listen to the account's object path, which is constructed as follows:
|
||||
|
||||
- for single-account mode: "/org/asamk/Signal/"
|
||||
- for multi-account mode: "/org/asamk/Signal/" + DBusNumber
|
||||
* DBusNumber: account's phone number, with underscore (_) replacing plus (+)
|
||||
- for single-account mode: "/org/asamk/Signal"
|
||||
- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier
|
||||
* DBusIdentifier: account's phone number with underscore (_) replacing plus (+),
|
||||
or its ACI with underscores replacing hyphens when there is no phone number
|
||||
|
||||
getContactName(number<s>) -> name<s>::
|
||||
* number : Phone number
|
||||
@ -135,7 +163,12 @@ May result in e.g. two entries if a contact and profile name is set.
|
||||
Exceptions: None
|
||||
|
||||
getSelfNumber() -> number<s>::
|
||||
* number : Your phone number
|
||||
* number : Your phone number, or an empty string for an account without a phone number
|
||||
|
||||
Exceptions: None
|
||||
|
||||
getSelfACI() -> aci<s>::
|
||||
* aci : Your account's ACI (Account ID), available with or without a phone number
|
||||
|
||||
Exceptions: None
|
||||
|
||||
@ -167,6 +200,14 @@ This is a concatenated list of all defined contacts as well of profiles known (e
|
||||
|
||||
Exceptions: None
|
||||
|
||||
listRecipientIdentifiers() -> identifiers<as>::
|
||||
* identifiers : String array of known recipient identifiers, including recipients without a phone number
|
||||
|
||||
Uses phone numbers where known and includes ACIs for recipients without a phone
|
||||
number. Unlike `listNumbers`, this method also includes numberless recipients.
|
||||
|
||||
Exceptions: None
|
||||
|
||||
removePin() -> <>::
|
||||
|
||||
Removes registration PIN protection.
|
||||
|
||||
@ -76,6 +76,9 @@ Messages are then sent similar to the automatic mode, but wrapped in a subscript
|
||||
When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts.
|
||||
|
||||
In multi-account mode, requests for a single account require an additional `account` param.
|
||||
For an account without a phone number, this parameter must be its ACI (Account ID).
|
||||
`listAccounts` returns the ACI in `aci` and a null `number` for these accounts.
|
||||
Receive events identify them by ACI in the `account` field.
|
||||
|
||||
REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}`
|
||||
|
||||
@ -132,7 +135,10 @@ Params:
|
||||
|
||||
REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}`
|
||||
|
||||
RESPONSE: `{"jsonrpc":"2.0","result":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X"},"id":"6"}`
|
||||
RESPONSE: `{"jsonrpc":"2.0","result":{"number":null,"aci":"11111111-1111-4111-8111-111111111111"},"id":"6"}`
|
||||
|
||||
The `number` field contains the phone number for a numbered account. Linking a numberless
|
||||
account uses the same QR flow; no Account Key is supplied to `finishLink`.
|
||||
|
||||
== Examples
|
||||
|
||||
|
||||
@ -19,7 +19,8 @@ signal-cli - A commandline interface for the Signal messenger
|
||||
|
||||
signal-cli is a commandline interface for libsignal-service-java.
|
||||
It supports registering, verifying, sending and receiving messages.
|
||||
For registering you need a phone number where you can receive SMS or incoming calls.
|
||||
Accounts can be registered using a phone number that can receive SMS or incoming calls.
|
||||
Existing accounts can also be recovered using the Account Key and Recovery Key shown by Signal Android.
|
||||
signal-cli was primarily developed to be used on servers to notify admins of important events.
|
||||
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
|
||||
|
||||
@ -65,6 +66,8 @@ Make sure you have full read/write access to the given directory.
|
||||
*-a* ACCOUNT, *--account* ACCOUNT::
|
||||
Specify your phone number, that will be your identifier.
|
||||
The phone number must include the country calling code, i.e. the number must start with a "+" sign.
|
||||
An ACI (the Account Key shown by Signal Android) can also select an account and is required for an account without a phone number.
|
||||
The Account Key can be given as 32 hexadecimal characters or as a UUID with dashes.
|
||||
|
||||
This flag must not be given for the `link` command.
|
||||
It is optional for the `daemon` command.
|
||||
@ -102,12 +105,13 @@ Disable message send log (for resending messages that recipient couldn't decrypt
|
||||
|
||||
=== register
|
||||
|
||||
Register a phone number with SMS or voice verification.
|
||||
Use the verify command to complete the verification.
|
||||
Register a phone number with SMS or voice verification, or recover an existing account using its Account Key and Recovery Key.
|
||||
Use the verify command to complete SMS or voice verification.
|
||||
|
||||
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
|
||||
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
|
||||
If the account was deleted (with --delete-account) it cannot be reactivated.
|
||||
If normal recovery of an existing local account fails and its Account Key and Recovery Key are available locally, recovery using those credentials is attempted before SMS verification.
|
||||
|
||||
*-v*, *--voice*::
|
||||
The verification should be done over voice, not SMS.
|
||||
@ -120,11 +124,23 @@ For the staging environment, use: https://signalcaptchas.org/staging/registratio
|
||||
After solving the captcha, right-click on the "Open Signal" link and copy the link.
|
||||
|
||||
*--reregister*::
|
||||
Register even if account is already registered.
|
||||
Register even if the local account is already registered.
|
||||
This option is required when using `--recovery-key` with an account that is still marked as registered locally.
|
||||
|
||||
*--recovery-key* RECOVERY-KEY::
|
||||
Recover an existing account using the 64-character Recovery Key shown by Signal Android.
|
||||
Specify the corresponding ACI (Account Key) with `-a`.
|
||||
This option cannot be combined with `--voice` or `--captcha`.
|
||||
|
||||
*--totp* TOKEN::
|
||||
The six-digit TOTP token requested during Account Key and Recovery Key based account recovery.
|
||||
If automatic account recovery reports that a TOTP token is required, rerun `register` with both `--recovery-key` and `--totp`.
|
||||
This option requires `--recovery-key`.
|
||||
This option cannot be combined with `--voice` or `--captcha`.
|
||||
|
||||
=== verify
|
||||
|
||||
Verify the number using the code received via SMS or voice.
|
||||
Verify a number using the code received via SMS or voice.
|
||||
|
||||
VERIFICATIONCODE::
|
||||
The verification code.
|
||||
@ -247,7 +263,12 @@ Remove the registration lock pin.
|
||||
Link to an existing device, instead of registering a new number.
|
||||
This shows a "sgnl://linkdevice?uuid=..." URI.
|
||||
If you want to connect to another signal-cli instance, you can just use this URI.
|
||||
If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
|
||||
If you want to link to an Android/iOS device, scan the QR code that signal-cli prints in the terminal with the Signal app.
|
||||
If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode).
|
||||
|
||||
Accounts without a phone number can also be linked.
|
||||
After linking, use the account's ACI (Account Key) with `-a`; recipient phone numbers must include the international country code.
|
||||
Existing primary accounts without a phone number can be recovered using the `register --recovery-key` command.
|
||||
|
||||
*-n* NAME, *--name* NAME::
|
||||
Optionally specify a name to describe this new device.
|
||||
@ -344,6 +365,15 @@ Data URI encoded attachments must follow the RFC 2397.
|
||||
Additionally a file name can be added:
|
||||
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
|
||||
|
||||
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
|
||||
Specify the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`.
|
||||
Use an empty string to skip one; image dimensions are otherwise detected automatically.
|
||||
|
||||
*--attachment-blurhash* [BLURHASH [BLURHASH ...]]::
|
||||
Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading.
|
||||
Use an empty string to skip one.
|
||||
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'`
|
||||
|
||||
*--view-once*::
|
||||
Send the message as a view once message.
|
||||
A conformant client will only allow the receiver to view the message once.
|
||||
|
||||
@ -20,6 +20,9 @@ import java.util.Map;
|
||||
*/
|
||||
public interface Signal extends DBusInterface {
|
||||
|
||||
/**
|
||||
* Returns the account's phone number, or an empty string for a numberless account.
|
||||
*/
|
||||
String getSelfNumber();
|
||||
|
||||
String getSelfACI();
|
||||
@ -186,6 +189,8 @@ public interface Signal extends DBusInterface {
|
||||
|
||||
List<String> listNumbers();
|
||||
|
||||
List<String> listRecipientIdentifiers();
|
||||
|
||||
List<String> getContactNumber(final String name) throws Error.Failure;
|
||||
|
||||
@Deprecated
|
||||
|
||||
@ -37,7 +37,10 @@ public interface SignalControl extends DBusInterface {
|
||||
|
||||
List<DBusPath> listAccounts();
|
||||
|
||||
DBusPath getAccount(String number);
|
||||
/**
|
||||
* Resolves a phone number or ACI to an exported account's object path.
|
||||
*/
|
||||
DBusPath getAccount(String identifier) throws Error.Failure;
|
||||
|
||||
interface Error {
|
||||
|
||||
|
||||
32
src/main/java/org/asamk/signal/AccountIdentifier.java
Normal file
32
src/main/java/org/asamk/signal/AccountIdentifier.java
Normal file
@ -0,0 +1,32 @@
|
||||
package org.asamk.signal;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class AccountIdentifier {
|
||||
|
||||
private AccountIdentifier() {
|
||||
}
|
||||
|
||||
public static String normalize(final String identifier) {
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final var compact = identifier.replace("-", "").replaceAll("\\s", "").toLowerCase(Locale.ROOT);
|
||||
if (compact.length() != 32 || !compact.matches("[0-9a-f]{32}")) {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
final var uuid = "%s-%s-%s-%s-%s".formatted(compact.substring(0, 8),
|
||||
compact.substring(8, 12),
|
||||
compact.substring(12, 16),
|
||||
compact.substring(16, 20),
|
||||
compact.substring(20));
|
||||
try {
|
||||
return UUID.fromString(uuid).toString();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return identifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,7 +142,7 @@ public class App {
|
||||
final var outputWriter = getOutputWriter(command);
|
||||
final var commandHandler = new CommandHandler(ns, outputWriter);
|
||||
|
||||
var account = ns.getString("account");
|
||||
var account = AccountIdentifier.normalize(ns.getString("account"));
|
||||
|
||||
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
|
||||
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
|
||||
@ -187,8 +187,8 @@ public class App {
|
||||
}
|
||||
|
||||
if (command instanceof RegistrationCommand registrationCommand) {
|
||||
if (!Manager.isValidNumber(account, null)) {
|
||||
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
|
||||
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
|
||||
throw new UserErrorException("Invalid account (E164 phone number or Account Key).");
|
||||
}
|
||||
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
|
||||
return;
|
||||
@ -208,14 +208,15 @@ public class App {
|
||||
private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException {
|
||||
Set<String> accounts;
|
||||
try {
|
||||
accounts = signalAccountFiles.getAllLocalAccountNumbers();
|
||||
accounts = signalAccountFiles.getAllLocalAccountIdentifiers();
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to load local accounts file", e);
|
||||
}
|
||||
if (accounts.isEmpty()) {
|
||||
throw new UserErrorException("No local users found, you first need to register or link an account");
|
||||
} else if (accounts.size() > 1) {
|
||||
throw new UserErrorException("Multiple users found, you need to specify an account (phone number) with -a");
|
||||
throw new UserErrorException(
|
||||
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
|
||||
}
|
||||
return accounts.stream().findFirst().get();
|
||||
}
|
||||
@ -223,9 +224,9 @@ public class App {
|
||||
private OutputWriter getOutputWriter(final Command command) throws UserErrorException {
|
||||
final var outputTypeInput = ns.<OutputType>get("output");
|
||||
final var outputType = outputTypeInput == null ? command.getSupportedOutputTypes()
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null) : outputTypeInput;
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null) : outputTypeInput;
|
||||
final var writer = new BufferedWriter(new OutputStreamWriter(System.out, IOUtils.getConsoleCharset()));
|
||||
final var outputWriter = outputType == null
|
||||
? null
|
||||
|
||||
@ -39,7 +39,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
||||
writer.println("Envelope from: {} (device: {}) to {}",
|
||||
source.map(this::formatContact).orElse("unknown source"),
|
||||
envelope.sourceDevice(),
|
||||
m.getSelfNumber());
|
||||
m.getSelfIdentifier());
|
||||
writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp()));
|
||||
writer.println("Server timestamps: received: {} delivered: {}",
|
||||
DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()),
|
||||
@ -55,13 +55,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
||||
final var recipientName = e.getSender().getLegacyIdentifier();
|
||||
writer.println(
|
||||
"Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted",
|
||||
m.getSelfNumber(),
|
||||
m.getSelfIdentifier(),
|
||||
recipientName,
|
||||
m.getSelfNumber(),
|
||||
m.getSelfIdentifier(),
|
||||
recipientName);
|
||||
writer.println(
|
||||
"If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification",
|
||||
m.getSelfNumber(),
|
||||
m.getSelfIdentifier(),
|
||||
recipientName);
|
||||
} else {
|
||||
writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName());
|
||||
@ -614,8 +614,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
||||
writer.println("Size: {}{}",
|
||||
attachment.size().isPresent() ? attachment.size().get() + " bytes" : "<unavailable>",
|
||||
attachment.preview().isPresent() ? " (Preview is available: "
|
||||
+ attachment.preview().get().length
|
||||
+ " bytes)" : "");
|
||||
+ attachment.preview().get().length
|
||||
+ " bytes)" : "");
|
||||
}
|
||||
if (attachment.thumbnail().isPresent()) {
|
||||
writer.println("Thumbnail:");
|
||||
|
||||
@ -54,9 +54,9 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
|
||||
if (deviceName == null) {
|
||||
deviceName = "cli";
|
||||
}
|
||||
final String number;
|
||||
final String identifier;
|
||||
try {
|
||||
number = provisioningManager.finishDeviceLink(deviceName);
|
||||
identifier = provisioningManager.finishDeviceLink(deviceName);
|
||||
} catch (TimeoutException e) {
|
||||
throw new UserErrorException("Link request timed out, please try again.");
|
||||
} catch (IOException e) {
|
||||
@ -73,10 +73,11 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
|
||||
+ "\" before trying again.");
|
||||
}
|
||||
|
||||
jsonWriter.write(new JsonFinishLink(number));
|
||||
final var manager = m.getManager(identifier);
|
||||
jsonWriter.write(new JsonFinishLink(manager.getSelfNumber(), manager.getSelfACI()));
|
||||
}
|
||||
|
||||
public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
|
||||
|
||||
private record JsonFinishLink(String number) {}
|
||||
private record JsonFinishLink(String number, String aci) {}
|
||||
}
|
||||
|
||||
@ -27,19 +27,22 @@ public class ListAccountsCommand implements JsonRpcMultiLocalCommand {
|
||||
final MultiAccountManager c,
|
||||
final OutputWriter outputWriter
|
||||
) throws CommandException {
|
||||
final var accountNumbers = c.getAccountNumbers();
|
||||
final var managers = c.getManagers();
|
||||
switch (outputWriter) {
|
||||
case JsonWriter jsonWriter -> {
|
||||
final var jsonAccounts = accountNumbers.stream().map(JsonAccount::new).toList();
|
||||
final var jsonAccounts = managers.stream()
|
||||
.map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI()))
|
||||
.toList();
|
||||
jsonWriter.write(jsonAccounts);
|
||||
}
|
||||
case PlainTextWriter plainTextWriter -> {
|
||||
for (final var number : accountNumbers) {
|
||||
plainTextWriter.println("Number: {}", number);
|
||||
for (final var manager : managers) {
|
||||
plainTextWriter.println(manager.getSelfNumber() != null ? "Number: {}" : "ACI: {}",
|
||||
manager.getSelfIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record JsonAccount(String number) {}
|
||||
private record JsonAccount(String number, String aci) {}
|
||||
}
|
||||
|
||||
@ -109,7 +109,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||
r.getProfile().getPhoneNumberSharingMode() == null
|
||||
? ""
|
||||
: String.valueOf(r.getProfile().getPhoneNumberSharingMode()
|
||||
== PhoneNumberSharingMode.EVERYBODY),
|
||||
== PhoneNumberSharingMode.EVERYBODY),
|
||||
r.getDiscoverable() == null ? "" : String.valueOf(r.getDiscoverable()));
|
||||
}
|
||||
}
|
||||
@ -121,17 +121,17 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||
final var jsonInternal = !internal
|
||||
? null
|
||||
: new JsonContact.JsonInternal(r.getProfile()
|
||||
.getCapabilities()
|
||||
.stream()
|
||||
.map(Enum::name)
|
||||
.toList(),
|
||||
.getCapabilities()
|
||||
.stream()
|
||||
.map(Enum::name)
|
||||
.toList(),
|
||||
r.getProfile().getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNKNOWN
|
||||
? null
|
||||
? null
|
||||
: r.getProfile().getUnidentifiedAccessMode().name(),
|
||||
r.getProfile().getPhoneNumberSharingMode() == null
|
||||
? null
|
||||
? null
|
||||
: r.getProfile().getPhoneNumberSharingMode()
|
||||
== PhoneNumberSharingMode.EVERYBODY,
|
||||
== PhoneNumberSharingMode.EVERYBODY,
|
||||
r.getDiscoverable());
|
||||
return new JsonContact(address.number().orElse(null),
|
||||
address.uuid().map(UUID::toString).orElse(null),
|
||||
@ -159,9 +159,9 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||
r.getProfile().getAboutEmoji(),
|
||||
r.getProfile().getAvatarUrlPath() != null,
|
||||
r.getProfile().getMobileCoinAddress() == null
|
||||
? null
|
||||
? null
|
||||
: Base64.getEncoder()
|
||||
.encodeToString(r.getProfile().getMobileCoinAddress())),
|
||||
.encodeToString(r.getProfile().getMobileCoinAddress())),
|
||||
jsonInternal);
|
||||
}).toList();
|
||||
writer.write(jsonContacts);
|
||||
|
||||
@ -15,6 +15,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
|
||||
import org.asamk.signal.manager.api.RateLimitException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
|
||||
import org.asamk.signal.output.JsonWriter;
|
||||
import org.asamk.signal.util.CommandUtil;
|
||||
@ -41,6 +42,8 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
subparser.addArgument("--reregister")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Register even if account is already registered");
|
||||
subparser.addArgument("--recovery-key").help("Recover an account using its 64-character Signal Recovery Key.");
|
||||
subparser.addArgument("--totp").help("A six-digit TOTP token required for account recovery.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -48,8 +51,10 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
|
||||
final var captcha = ns.getString("captcha");
|
||||
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
|
||||
final var recoveryKey = ns.getString("recovery-key");
|
||||
final var totp = ns.getString("totp");
|
||||
|
||||
register(m, voiceVerification, captcha, reregister);
|
||||
register(m, voiceVerification, captcha, reregister, recoveryKey, totp);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -68,15 +73,53 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
final RegistrationManager m,
|
||||
final JsonWriter jsonWriter
|
||||
) throws CommandException {
|
||||
register(m, Boolean.TRUE.equals(request.voice()), request.captcha(), Boolean.TRUE.equals(request.reregister()));
|
||||
register(m,
|
||||
Boolean.TRUE.equals(request.voice()),
|
||||
request.captcha(),
|
||||
Boolean.TRUE.equals(request.reregister()),
|
||||
request.recoveryKey(),
|
||||
request.totp());
|
||||
}
|
||||
|
||||
private void register(
|
||||
final RegistrationManager m,
|
||||
final boolean voiceVerification,
|
||||
final String captcha,
|
||||
final boolean reregister
|
||||
final boolean reregister,
|
||||
final String recoveryKey,
|
||||
final String totpValue
|
||||
) throws CommandException {
|
||||
if (recoveryKey == null && totpValue != null) {
|
||||
throw new UserErrorException("--totp requires --recovery-key");
|
||||
}
|
||||
|
||||
final Integer totp;
|
||||
if (totpValue == null) {
|
||||
totp = null;
|
||||
} else if (!totpValue.matches("[0-9]{6}")) {
|
||||
throw new UserErrorException("TOTP token must contain exactly six digits");
|
||||
} else {
|
||||
totp = Integer.parseInt(totpValue);
|
||||
}
|
||||
|
||||
if (recoveryKey != null) {
|
||||
if (voiceVerification || captcha != null) {
|
||||
throw new UserErrorException("--recovery-key cannot be combined with --voice or --captcha");
|
||||
}
|
||||
try {
|
||||
m.registerWithRecoveryKey(recoveryKey, reregister, totp);
|
||||
} catch (RateLimitException e) {
|
||||
final var message = CommandUtil.getRateLimitMessage(e);
|
||||
throw new RateLimitErrorException(message, e);
|
||||
} catch (TotpRequiredException e) {
|
||||
throw new UserErrorException("A TOTP token is required; rerun register with --totp TOKEN");
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
||||
e.getClass().getSimpleName()), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
m.register(voiceVerification, captcha, reregister);
|
||||
} catch (RateLimitException e) {
|
||||
@ -87,6 +130,9 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
throw new UserErrorException(message);
|
||||
} catch (NonNormalizedPhoneNumberException e) {
|
||||
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
|
||||
} catch (TotpRequiredException e) {
|
||||
throw new UserErrorException(
|
||||
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
|
||||
} catch (IOException e) {
|
||||
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
|
||||
e.getClass().getSimpleName()), e);
|
||||
@ -99,5 +145,11 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
|
||||
}
|
||||
}
|
||||
|
||||
public record RegistrationParams(Boolean voice, String captcha, Boolean reregister) {}
|
||||
public record RegistrationParams(
|
||||
Boolean voice,
|
||||
String captcha,
|
||||
Boolean reregister,
|
||||
String recoveryKey,
|
||||
String totp
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
|
||||
public class SendCommand implements JsonRpcLocalCommand {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SendCommand.class);
|
||||
private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
@ -112,6 +113,13 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
subparser.addArgument("--voice-note")
|
||||
.action(Arguments.storeTrue())
|
||||
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
|
||||
subparser.addArgument("--attachment-dimensions")
|
||||
.nargs("*")
|
||||
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
|
||||
subparser.addArgument("--attachment-blurhash")
|
||||
.nargs("*")
|
||||
.help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show "
|
||||
+ "while it's downloading. Use an empty string to skip one.");
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -175,6 +183,13 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
|
||||
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
|
||||
|
||||
final var dimensionStrings = ns.<String>getList("attachment-dimensions");
|
||||
final var attachmentDimensions = dimensionStrings == null
|
||||
? List.<Message.AttachmentDimensions>of()
|
||||
: parseAttachmentDimensions(dimensionStrings);
|
||||
final var blurHashes = ns.<String>getList("attachment-blurhash");
|
||||
final var attachmentBlurHashes = blurHashes == null ? List.<String>of() : parseAttachmentBlurHashes(blurHashes);
|
||||
|
||||
final var selfNumber = m.getSelfNumber();
|
||||
|
||||
final var mentionStrings = ns.<String>getList("mention");
|
||||
@ -249,6 +264,8 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
attachmentDimensions,
|
||||
attachmentBlurHashes,
|
||||
viewOnce,
|
||||
voiceNote,
|
||||
mentions,
|
||||
@ -280,6 +297,46 @@ public class SendCommand implements JsonRpcLocalCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
|
||||
final List<String> dimensionStrings
|
||||
) throws UserErrorException {
|
||||
final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)");
|
||||
final var dimensions = new ArrayList<Message.AttachmentDimensions>();
|
||||
for (final var dimension : dimensionStrings) {
|
||||
if (dimension.isEmpty()) {
|
||||
dimensions.add(null);
|
||||
continue;
|
||||
}
|
||||
final var matcher = dimensionPattern.matcher(dimension);
|
||||
if (!matcher.matches()) {
|
||||
throw new UserErrorException("Invalid attachment dimensions syntax ("
|
||||
+ dimension
|
||||
+ ") expected 'WIDTHxHEIGHT'");
|
||||
}
|
||||
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)),
|
||||
Integer.parseInt(matcher.group(2))));
|
||||
}
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
private List<String> parseAttachmentBlurHashes(final List<String> blurHashes) throws UserErrorException {
|
||||
for (final var blurHash : blurHashes) {
|
||||
if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) {
|
||||
throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")");
|
||||
}
|
||||
}
|
||||
return blurHashes;
|
||||
}
|
||||
|
||||
// Same check the BlurHash decoders make: the first digit fixes the length.
|
||||
private static boolean isValidBlurHash(final String blurHash) {
|
||||
if (blurHash.length() < 6) {
|
||||
return false;
|
||||
}
|
||||
final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0));
|
||||
return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1);
|
||||
}
|
||||
|
||||
private List<Message.Mention> parseMentions(
|
||||
final String selfNumber,
|
||||
final List<String> mentionStrings
|
||||
|
||||
@ -55,7 +55,7 @@ public class DbusCommandHandler {
|
||||
try {
|
||||
if (command instanceof ProvisioningCommand c) {
|
||||
if (account != null) {
|
||||
throw new UserErrorException("You cannot specify a account (phone number) when linking");
|
||||
throw new UserErrorException("You cannot specify an account when linking");
|
||||
}
|
||||
|
||||
handleProvisioningCommand(c, dBusConn, busname, commandHandler);
|
||||
@ -74,7 +74,9 @@ public class DbusCommandHandler {
|
||||
throw new UserErrorException("Command only works in multi-account mode");
|
||||
}
|
||||
|
||||
var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null;
|
||||
var accountObjectPath = account == null
|
||||
? tryGetSingleAccountObjectPath(dBusConn, busname)
|
||||
: getAccountObjectPath(account, dBusConn, busname);
|
||||
if (accountObjectPath == null) {
|
||||
accountObjectPath = DbusConfig.getObjectPath(account);
|
||||
}
|
||||
@ -86,6 +88,24 @@ public class DbusCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private static String getAccountObjectPath(
|
||||
final String account,
|
||||
final DBusConnection dBusConn,
|
||||
final String busname
|
||||
) throws DBusException, CommandException {
|
||||
final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
|
||||
try {
|
||||
return control.getAccount(account).getPath();
|
||||
} catch (UnknownMethod e) {
|
||||
// A single-account daemon exports Signal directly at the base path.
|
||||
final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
|
||||
if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) {
|
||||
throw new UserErrorException("Unknown account: " + account);
|
||||
}
|
||||
return DbusConfig.getObjectPath();
|
||||
}
|
||||
}
|
||||
|
||||
private static String tryGetSingleAccountObjectPath(
|
||||
final DBusConnection dBusConn,
|
||||
final String busname
|
||||
@ -97,7 +117,7 @@ public class DbusCommandHandler {
|
||||
throw new UserErrorException("No local users found, you first need to register or link an account");
|
||||
} else if (accounts.size() > 1) {
|
||||
throw new UserErrorException(
|
||||
"Multiple users found, you need to specify an account (phone number) with -a");
|
||||
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
|
||||
}
|
||||
|
||||
return accounts.getFirst().getPath();
|
||||
|
||||
@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable {
|
||||
}
|
||||
});
|
||||
c.addOnManagerRemovedHandler(m -> {
|
||||
final var path = DbusConfig.getObjectPath(m.getSelfNumber());
|
||||
final var path = DbusConfig.getObjectPath(m.getSelfIdentifier());
|
||||
try {
|
||||
final var object = connection.getExportedObject(null, path);
|
||||
if (object instanceof DbusSignalImpl dbusSignal) {
|
||||
@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable {
|
||||
final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart);
|
||||
closeables.add(signal);
|
||||
|
||||
return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects);
|
||||
return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects);
|
||||
}
|
||||
|
||||
private Thread exportManager(final DBusConnection conn, final Manager m) {
|
||||
final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber());
|
||||
final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier());
|
||||
return exportDbusObject(conn, objectPath, m);
|
||||
}
|
||||
|
||||
|
||||
@ -77,6 +77,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
@ -110,7 +111,8 @@ public class DbusManagerImpl implements Manager {
|
||||
|
||||
@Override
|
||||
public String getSelfNumber() {
|
||||
return signal.getSelfNumber();
|
||||
final var number = signal.getSelfNumber();
|
||||
return number.isEmpty() ? null : number;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -438,7 +440,7 @@ public class DbusManagerImpl implements Manager {
|
||||
numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP));
|
||||
return 0L;
|
||||
}, () -> {
|
||||
signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP);
|
||||
signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP);
|
||||
return 0L;
|
||||
}, groupId -> {
|
||||
signal.sendGroupTyping(groupId, action == TypingAction.STOP);
|
||||
@ -486,7 +488,7 @@ public class DbusManagerImpl implements Manager {
|
||||
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
|
||||
return handleMessage(recipients,
|
||||
numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers),
|
||||
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()),
|
||||
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()),
|
||||
groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId));
|
||||
}
|
||||
|
||||
@ -510,7 +512,7 @@ public class DbusManagerImpl implements Manager {
|
||||
remove,
|
||||
targetAuthor.getIdentifier(),
|
||||
targetSentTimestamp,
|
||||
signal.getSelfNumber()),
|
||||
getSelfIdentifier()),
|
||||
groupId -> signal.sendGroupMessageReaction(emoji,
|
||||
remove,
|
||||
targetAuthor.getIdentifier(),
|
||||
@ -809,43 +811,47 @@ public class DbusManagerImpl implements Manager {
|
||||
final Collection<RecipientIdentifier.Single> addresses,
|
||||
final Optional<String> name
|
||||
) {
|
||||
final var numbers = addresses.stream()
|
||||
.filter(s -> s instanceof RecipientIdentifier.Number)
|
||||
.map(s -> ((RecipientIdentifier.Number) s).number())
|
||||
final var identifiers = addresses.stream()
|
||||
.map(RecipientIdentifier.Single::getIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> {
|
||||
final var contactBlocked = signal.isContactBlocked(n);
|
||||
if (blocked.isPresent() && blocked.get() != contactBlocked) {
|
||||
return null;
|
||||
}
|
||||
final var contactName = signal.getContactName(n);
|
||||
if (onlyContacts && contactName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (name.isPresent() && !name.get().equals(contactName)) {
|
||||
return null;
|
||||
}
|
||||
return Recipient.newBuilder()
|
||||
.withAddress(new RecipientAddress(n))
|
||||
.withContact(new Contact(contactName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
contactBlocked,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null))
|
||||
.build();
|
||||
}).filter(Objects::nonNull).toList();
|
||||
return signal.listRecipientIdentifiers()
|
||||
.stream()
|
||||
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
|
||||
.map(n -> {
|
||||
final var contactBlocked = signal.isContactBlocked(n);
|
||||
if (blocked.isPresent() && blocked.get() != contactBlocked) {
|
||||
return null;
|
||||
}
|
||||
final var contactName = signal.getContactName(n);
|
||||
if (onlyContacts && contactName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (name.isPresent() && !name.get().equals(contactName)) {
|
||||
return null;
|
||||
}
|
||||
return Recipient.newBuilder()
|
||||
.withAddress(getRecipientAddress(n))
|
||||
.withContact(new Contact(contactName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
contactBlocked,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null))
|
||||
.build();
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -870,16 +876,16 @@ public class DbusManagerImpl implements Manager {
|
||||
(String) group.get("Description").getValue(),
|
||||
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
|
||||
((List<String>) group.get("Members").getValue()).stream()
|
||||
.map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
|
||||
.map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null))
|
||||
.collect(Collectors.toSet()),
|
||||
((List<String>) group.get("PendingMembers").getValue()).stream()
|
||||
.map(RecipientAddress::new)
|
||||
.map(DbusManagerImpl::getRecipientAddress)
|
||||
.collect(Collectors.toSet()),
|
||||
((List<String>) group.get("RequestingMembers").getValue()).stream()
|
||||
.map(RecipientAddress::new)
|
||||
.map(DbusManagerImpl::getRecipientAddress)
|
||||
.collect(Collectors.toSet()),
|
||||
((List<String>) group.get("Banned").getValue()).stream()
|
||||
.map(RecipientAddress::new)
|
||||
.map(DbusManagerImpl::getRecipientAddress)
|
||||
.collect(Collectors.toSet()),
|
||||
(boolean) group.get("IsBlocked").getValue(),
|
||||
(int) group.get("MessageExpirationTimer").getValue(),
|
||||
@ -910,7 +916,10 @@ public class DbusManagerImpl implements Manager {
|
||||
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
|
||||
final var aci = (String) group.get("Uuid").getValue();
|
||||
final var number = (String) group.get("Number").getValue();
|
||||
return new Identity(new RecipientAddress(aci, null, number, null),
|
||||
return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci,
|
||||
null,
|
||||
number.isEmpty() ? null : number,
|
||||
null),
|
||||
(byte[]) group.get("Fingerprint").getValue(),
|
||||
(String) group.get("SafetyNumber").getValue(),
|
||||
(byte[]) group.get("ScannableSafetyNumber").getValue(),
|
||||
@ -1073,6 +1082,14 @@ public class DbusManagerImpl implements Manager {
|
||||
return string == null ? "" : string;
|
||||
}
|
||||
|
||||
private static RecipientAddress getRecipientAddress(final String identifier) {
|
||||
try {
|
||||
return new RecipientAddress(UUID.fromString(identifier));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new RecipientAddress(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
|
||||
try {
|
||||
return connection.getRemoteObject(busname, path.getPath(), type);
|
||||
@ -1085,7 +1102,7 @@ public class DbusManagerImpl implements Manager {
|
||||
try {
|
||||
this.dbusMsgHandler = messageReceived -> {
|
||||
final var extras = messageReceived.getExtras();
|
||||
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
|
||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
|
||||
0,
|
||||
messageReceived.getTimestamp(),
|
||||
0,
|
||||
@ -1132,7 +1149,7 @@ public class DbusManagerImpl implements Manager {
|
||||
connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler);
|
||||
this.dbusEditMsgHandler = messageReceived -> {
|
||||
final var extras = messageReceived.getExtras();
|
||||
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
|
||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
|
||||
0,
|
||||
messageReceived.getTimestamp(),
|
||||
0,
|
||||
@ -1186,7 +1203,7 @@ public class DbusManagerImpl implements Manager {
|
||||
case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY;
|
||||
default -> MessageEnvelope.Receipt.Type.UNKNOWN;
|
||||
};
|
||||
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())),
|
||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())),
|
||||
0,
|
||||
receiptReceived.getTimestamp(),
|
||||
0,
|
||||
@ -1207,7 +1224,7 @@ public class DbusManagerImpl implements Manager {
|
||||
|
||||
this.dbusSyncHandler = syncReceived -> {
|
||||
final var extras = syncReceived.getExtras();
|
||||
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())),
|
||||
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())),
|
||||
0,
|
||||
syncReceived.getTimestamp(),
|
||||
0,
|
||||
@ -1221,7 +1238,7 @@ public class DbusManagerImpl implements Manager {
|
||||
syncReceived.getTimestamp(),
|
||||
syncReceived.getDestination().isEmpty()
|
||||
? Optional.empty()
|
||||
: Optional.of(new RecipientAddress(syncReceived.getDestination())),
|
||||
: Optional.of(getRecipientAddress(syncReceived.getDestination())),
|
||||
Set.of(),
|
||||
Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(),
|
||||
syncReceived.getGroupId().length > 0
|
||||
@ -1326,7 +1343,7 @@ public class DbusManagerImpl implements Manager {
|
||||
|
||||
final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
|
||||
return mentions.stream()
|
||||
.map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.<String>getValue(a, "recipient")),
|
||||
.map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.<String>getValue(a, "recipient")),
|
||||
getValue(a, "start"),
|
||||
getValue(a, "length")))
|
||||
.toList();
|
||||
|
||||
@ -43,14 +43,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
||||
this.busname = busname;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAccountNumbers() {
|
||||
return signalControl.listAccounts()
|
||||
.stream()
|
||||
.map(a -> getRemoteObject(a, Signal.class).getSelfNumber())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Manager> getManagers() {
|
||||
return signalControl.listAccounts()
|
||||
@ -74,8 +66,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manager getManager(final String phoneNumber) {
|
||||
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class),
|
||||
public Manager getManager(final String identifier) {
|
||||
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class),
|
||||
connection,
|
||||
busname);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import org.asamk.signal.manager.RegistrationManager;
|
||||
import org.asamk.signal.manager.api.CaptchaRequiredException;
|
||||
import org.asamk.signal.manager.api.IncorrectPinException;
|
||||
import org.asamk.signal.manager.api.PinLockedException;
|
||||
import org.asamk.signal.manager.api.TotpRequiredException;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -53,6 +54,15 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWithRecoveryKey(
|
||||
final String recoveryKey,
|
||||
final boolean forceRegister,
|
||||
final Integer totp
|
||||
) throws IOException, TotpRequiredException {
|
||||
throw new IOException("Recovery-key registration is not supported over D-Bus");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteLocalAccountData() throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
|
||||
@ -159,11 +159,19 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl {
|
||||
|
||||
@Override
|
||||
public List<DBusPath> listAccounts() {
|
||||
return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList();
|
||||
return c.getManagers()
|
||||
.stream()
|
||||
.map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DBusPath getAccount(final String number) {
|
||||
return new DBusPath(DbusConfig.getObjectPath(number));
|
||||
public DBusPath getAccount(final String identifier) {
|
||||
final var manager = c.getManagers()
|
||||
.stream()
|
||||
.filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new Error.Failure("Unknown account: " + identifier));
|
||||
return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
|
||||
@Override
|
||||
public String getSelfNumber() {
|
||||
return m.getSelfNumber();
|
||||
return emptyIfNull(m.getSelfNumber());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -242,6 +242,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
@ -409,6 +411,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
@ -456,6 +460,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
try {
|
||||
final var message = new Message(messageText,
|
||||
attachments,
|
||||
List.of(),
|
||||
List.of(),
|
||||
false,
|
||||
false,
|
||||
List.of(),
|
||||
@ -709,7 +715,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
retryAfterMilliseconds == null
|
||||
? ""
|
||||
: ", retry at " + DateUtils.formatTimestamp(System.currentTimeMillis()
|
||||
+ retryAfterMilliseconds)
|
||||
+ retryAfterMilliseconds)
|
||||
));
|
||||
}
|
||||
|
||||
@ -797,6 +803,15 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listRecipientIdentifiers() {
|
||||
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
|
||||
.stream()
|
||||
.map(r -> r.getAddress().getLegacyIdentifier())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getContactNumber(final String name) {
|
||||
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))
|
||||
|
||||
@ -43,7 +43,7 @@ record JsonDataMessage(
|
||||
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
|
||||
final var timestamp = dataMessage.timestamp();
|
||||
final var groupInfo = dataMessage.groupContext().isPresent() ? JsonGroupInfo.from(dataMessage.groupContext()
|
||||
.get(), m) : null;
|
||||
.get(), m) : null;
|
||||
final var storyContext = dataMessage.storyContext().isPresent()
|
||||
? JsonStoryContext.from(dataMessage.storyContext().get())
|
||||
: null;
|
||||
@ -59,32 +59,32 @@ record JsonDataMessage(
|
||||
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
|
||||
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
|
||||
final var mentions = !dataMessage.mentions().isEmpty() ? dataMessage.mentions()
|
||||
.stream()
|
||||
.map(JsonMention::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonMention::from)
|
||||
.toList() : null;
|
||||
final var previews = !dataMessage.previews().isEmpty() ? dataMessage.previews()
|
||||
.stream()
|
||||
.map(JsonPreview::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonPreview::from)
|
||||
.toList() : null;
|
||||
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
|
||||
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
|
||||
: null;
|
||||
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
|
||||
.stream()
|
||||
.map(JsonAttachment::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonAttachment::from)
|
||||
.toList() : null;
|
||||
final var sticker = dataMessage.sticker().isPresent() ? JsonSticker.from(dataMessage.sticker().get()) : null;
|
||||
final var contacts = !dataMessage.sharedContacts().isEmpty() ? dataMessage.sharedContacts()
|
||||
.stream()
|
||||
.map(JsonSharedContact::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonSharedContact::from)
|
||||
.toList() : null;
|
||||
final var pollCreate = dataMessage.pollCreate().map(JsonPollCreate::from).orElse(null);
|
||||
final var pollVote = dataMessage.pollVote().map(JsonPollVote::from).orElse(null);
|
||||
final var pollTerminate = dataMessage.pollTerminate().map(JsonPollTerminate::from).orElse(null);
|
||||
final var textStyles = !dataMessage.textStyles().isEmpty() ? dataMessage.textStyles()
|
||||
.stream()
|
||||
.map(JsonTextStyle::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonTextStyle::from)
|
||||
.toList() : null;
|
||||
final var pinMessage = dataMessage.pinMessage().map(JsonPinMessage::from).orElse(null);
|
||||
final var unpinMessage = dataMessage.unpinMessage().map(JsonUnpinMessage::from).orElse(null);
|
||||
final var adminDelete = dataMessage.adminDelete().map(JsonAdminDelete::from).orElse(null);
|
||||
|
||||
@ -34,14 +34,14 @@ public record JsonQuote(
|
||||
: null;
|
||||
|
||||
final var attachments = !quote.attachments().isEmpty() ? quote.attachments()
|
||||
.stream()
|
||||
.map(JsonQuotedAttachment::from)
|
||||
.toList() : List.<JsonQuotedAttachment>of();
|
||||
.stream()
|
||||
.map(JsonQuotedAttachment::from)
|
||||
.toList() : List.<JsonQuotedAttachment>of();
|
||||
|
||||
final var textStyles = !quote.textStyles().isEmpty() ? quote.textStyles()
|
||||
.stream()
|
||||
.map(JsonTextStyle::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonTextStyle::from)
|
||||
.toList() : null;
|
||||
|
||||
return new JsonQuote(id, author, authorNumber, authorUuid, text, mentions, attachments, textStyles);
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler
|
||||
@Override
|
||||
public void handleMessage(MessageEnvelope envelope, Throwable exception) {
|
||||
final var object = new HashMap<String, Object>();
|
||||
object.put("account", m.getSelfNumber());
|
||||
object.put("account", m.getSelfIdentifier());
|
||||
if (exception != null) {
|
||||
object.put("exception", JsonError.from(exception));
|
||||
}
|
||||
|
||||
@ -27,13 +27,13 @@ public record JsonSendMessageResult(
|
||||
result.isSuccess()
|
||||
? Type.SUCCESS
|
||||
: result.isRateLimitFailure()
|
||||
? Type.RATE_LIMIT_FAILURE
|
||||
? Type.RATE_LIMIT_FAILURE
|
||||
: result.isNetworkFailure()
|
||||
? Type.NETWORK_FAILURE
|
||||
? Type.NETWORK_FAILURE
|
||||
: result.isUnregisteredFailure()
|
||||
? Type.UNREGISTERED_FAILURE
|
||||
? Type.UNREGISTERED_FAILURE
|
||||
: result.isInvalidPreKeyFailure()
|
||||
? Type.INVALID_PRE_KEY_FAILURE
|
||||
? Type.INVALID_PRE_KEY_FAILURE
|
||||
: Type.IDENTITY_FAILURE,
|
||||
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
|
||||
rateLimitRetryAfterMilliseconds == null ? null : Math.ceilDiv(rateLimitRetryAfterMilliseconds, 1000L));
|
||||
|
||||
@ -31,9 +31,9 @@ public record JsonSharedContact(
|
||||
: null;
|
||||
|
||||
final var address = !contact.address().isEmpty() ? contact.address()
|
||||
.stream()
|
||||
.map(JsonContactAddress::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonContactAddress::from)
|
||||
.toList() : null;
|
||||
|
||||
final var organization = contact.organization().orElse(null);
|
||||
|
||||
|
||||
@ -50,9 +50,9 @@ record JsonSyncMessage(
|
||||
}
|
||||
|
||||
final var readMessages = !syncMessage.read().isEmpty() ? syncMessage.read()
|
||||
.stream()
|
||||
.map(JsonSyncReadMessage::from)
|
||||
.toList() : null;
|
||||
.stream()
|
||||
.map(JsonSyncReadMessage::from)
|
||||
.toList() : null;
|
||||
|
||||
final JsonSyncMessageType type;
|
||||
if (syncMessage.contacts().isPresent()) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package org.asamk.signal.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.StreamReadConstraints;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -8,6 +9,7 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.ValueNode;
|
||||
|
||||
import org.asamk.signal.manager.config.ServiceConfig;
|
||||
import org.asamk.signal.util.Util;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -24,6 +26,8 @@ import java.util.stream.StreamSupport;
|
||||
public class JsonRpcReader {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
|
||||
private static final int MAX_JSON_STRING_LENGTH =
|
||||
((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
|
||||
|
||||
private final JsonRpcSender jsonRpcSender;
|
||||
private final ObjectMapper objectMapper;
|
||||
@ -34,14 +38,22 @@ public class JsonRpcReader {
|
||||
this.jsonRpcSender = jsonRpcSender;
|
||||
this.input = null;
|
||||
this.lineSupplier = lineSupplier;
|
||||
this.objectMapper = Util.createJsonObjectMapper();
|
||||
this.objectMapper = createObjectMapper();
|
||||
}
|
||||
|
||||
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
|
||||
this.jsonRpcSender = jsonRpcSender;
|
||||
this.input = input;
|
||||
this.lineSupplier = null;
|
||||
this.objectMapper = Util.createJsonObjectMapper();
|
||||
this.objectMapper = createObjectMapper();
|
||||
}
|
||||
|
||||
private static ObjectMapper createObjectMapper() {
|
||||
final var objectMapper = Util.createJsonObjectMapper();
|
||||
objectMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder()
|
||||
.maxStringLength(MAX_JSON_STRING_LENGTH)
|
||||
.build());
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) {
|
||||
|
||||
@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.asamk.signal.AccountIdentifier;
|
||||
import org.asamk.signal.commands.Command;
|
||||
import org.asamk.signal.commands.JsonRpcMultiCommand;
|
||||
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
|
||||
@ -126,7 +127,7 @@ public class SignalJsonRpcCommandHandler {
|
||||
|
||||
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
|
||||
if (params != null && params.hasNonNull("account")) {
|
||||
final var manager = c.getManager(params.get("account").asText());
|
||||
final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText()));
|
||||
((ObjectNode) params).remove("account");
|
||||
if (manager == null) {
|
||||
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
|
||||
@ -140,7 +141,7 @@ public class SignalJsonRpcCommandHandler {
|
||||
|
||||
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
|
||||
if (params != null && params.has("account")) {
|
||||
final var account = params.get("account").asText();
|
||||
final var account = AccountIdentifier.normalize(params.get("account").asText());
|
||||
((ObjectNode) params).remove("account");
|
||||
try {
|
||||
return new Pair<>(account, c.getNewRegistrationManager(account));
|
||||
|
||||
@ -3239,6 +3239,9 @@
|
||||
"allDeclaredMethods": true,
|
||||
"allDeclaredConstructors": true
|
||||
},
|
||||
{
|
||||
"type": "org.asamk.signal.json.JsonGroupCallUpdate"
|
||||
},
|
||||
{
|
||||
"type": "org.asamk.signal.json.JsonGroupInfo",
|
||||
"allDeclaredFields": true,
|
||||
@ -6190,6 +6193,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.internal.LinkedDeviceInternal_ReturnConverter",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "fromNative",
|
||||
"parameterTypes": [
|
||||
"java.lang.Object",
|
||||
"java.lang.Object",
|
||||
"java.lang.Object",
|
||||
"java.lang.Object",
|
||||
"java.lang.Object"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
|
||||
"jniAccessible": true,
|
||||
@ -6234,6 +6253,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.ChallengeOption",
|
||||
"jniAccessible": true,
|
||||
"fields": [
|
||||
{
|
||||
"name": "CAPTCHA"
|
||||
},
|
||||
{
|
||||
"name": "PUSH_CHALLENGE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.ChatConnection$ListenerBridge",
|
||||
"jniAccessible": true,
|
||||
@ -6357,6 +6388,46 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.DeviceIdNotFoundException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.MismatchedDeviceException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String",
|
||||
"org.signal.libsignal.net.MismatchedDeviceException$Entry[]"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.MismatchedDeviceException$Entry",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"byte[]",
|
||||
"int[]",
|
||||
"int[]",
|
||||
"int[]"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.NetworkException",
|
||||
"jniAccessible": true,
|
||||
@ -6369,6 +6440,33 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.RateLimitChallengeException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String",
|
||||
"java.lang.String",
|
||||
"org.signal.libsignal.net.ChallengeOption[]",
|
||||
"long"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.RequestUnauthorizedException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.RetryLaterException",
|
||||
"jniAccessible": true,
|
||||
@ -6381,6 +6479,42 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.ServerSideErrorException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.ServiceIdNotFoundException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.TimeoutException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.TransportFailureException",
|
||||
"jniAccessible": true,
|
||||
@ -6393,6 +6527,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.UnexpectedResponseException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.UploadForm",
|
||||
"jniAccessible": true,
|
||||
@ -6408,6 +6554,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.UploadTooLargeException",
|
||||
"jniAccessible": true,
|
||||
"methods": [
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "org.signal.libsignal.net.internal.BridgeChatListener",
|
||||
"jniAccessible": true
|
||||
@ -6492,6 +6650,13 @@
|
||||
"parameterTypes": [
|
||||
"java.lang.String"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "<init>",
|
||||
"parameterTypes": [
|
||||
"org.signal.libsignal.protocol.SignalProtocolAddress",
|
||||
"java.lang.String"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
23
src/test/java/org/asamk/signal/AccountIdentifierTest.java
Normal file
23
src/test/java/org/asamk/signal/AccountIdentifierTest.java
Normal file
@ -0,0 +1,23 @@
|
||||
package org.asamk.signal;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AccountIdentifierTest {
|
||||
|
||||
@Test
|
||||
void normalizesSignalAndroidAccountKeys() {
|
||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
||||
AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
|
||||
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
|
||||
AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesNonAccountIdentifiersUnchanged() {
|
||||
assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
|
||||
assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
|
||||
assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
package org.asamk.signal.commands;
|
||||
|
||||
import net.sourceforge.argparse4j.ArgumentParsers;
|
||||
import net.sourceforge.argparse4j.DefaultSettings;
|
||||
import net.sourceforge.argparse4j.inf.Namespace;
|
||||
|
||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.Message;
|
||||
import org.asamk.signal.manager.api.SendMessageResults;
|
||||
import org.asamk.signal.output.JsonWriter;
|
||||
import org.asamk.signal.testutil.ManagerMock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SendCommandParsingTest {
|
||||
|
||||
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
|
||||
private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
|
||||
|
||||
@Test
|
||||
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
|
||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
||||
.includeArgumentNamesAsKeysInResult(true)
|
||||
.build();
|
||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
||||
final var message = send(parser.parseArgs(new String[]{
|
||||
"send",
|
||||
"--note-to-self",
|
||||
"-a",
|
||||
"photo.jpg",
|
||||
"portrait.mp4",
|
||||
"other.mp4",
|
||||
"--attachment-dimensions",
|
||||
"",
|
||||
"1080x1920"
|
||||
}));
|
||||
|
||||
assertEquals(ATTACHMENTS, message.attachments());
|
||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
|
||||
final var message = send(namespace(List.of("", "1920x1080")));
|
||||
assertEquals(ATTACHMENTS, message.attachments());
|
||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cliPreservesBlurHashPositions() throws Exception {
|
||||
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
|
||||
.includeArgumentNamesAsKeysInResult(true)
|
||||
.build();
|
||||
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
|
||||
final var message = send(parser.parseArgs(new String[]{
|
||||
"send",
|
||||
"--note-to-self",
|
||||
"-a",
|
||||
"photo.jpg",
|
||||
"portrait.mp4",
|
||||
"other.mp4",
|
||||
"--attachment-blurhash",
|
||||
"",
|
||||
BLUR_HASH
|
||||
}));
|
||||
|
||||
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRpcPassesBlurHashesWithDimensions() throws Exception {
|
||||
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf",
|
||||
true,
|
||||
"attachments",
|
||||
ATTACHMENTS,
|
||||
"attachmentDimensions",
|
||||
List.of("", "1920x1080"),
|
||||
"attachmentBlurhash",
|
||||
List.of("", BLUR_HASH))));
|
||||
|
||||
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
|
||||
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void omittedDimensionsKeepExistingBehavior() throws Exception {
|
||||
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS)));
|
||||
assertEquals(List.of(), message.attachmentDimensions());
|
||||
assertEquals(List.of(), message.attachmentBlurHashes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedDimensionsBeforeSending() {
|
||||
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) {
|
||||
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedBlurHashesBeforeSending() {
|
||||
// Wrong length for its size digit, an unknown size digit, too short.
|
||||
for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) {
|
||||
assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonRpcNamespace blurHashNamespace(List<String> blurHashes) {
|
||||
return new JsonRpcNamespace(Map.of("noteToSelf",
|
||||
true,
|
||||
"attachments",
|
||||
ATTACHMENTS,
|
||||
"attachmentBlurhash",
|
||||
blurHashes));
|
||||
}
|
||||
|
||||
private JsonRpcNamespace namespace(List<String> dimensions) {
|
||||
return new JsonRpcNamespace(Map.of("noteToSelf",
|
||||
true,
|
||||
"attachments",
|
||||
ATTACHMENTS,
|
||||
"attachmentDimensions",
|
||||
dimensions));
|
||||
}
|
||||
|
||||
private Message send(Namespace namespace) throws Exception {
|
||||
final var captured = new AtomicReference<Message>();
|
||||
final var delegate = ManagerMock.create("+15551234567");
|
||||
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
||||
new Class<?>[]{Manager.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.getName().equals("sendMessage")) {
|
||||
captured.set((Message) args[0]);
|
||||
return new SendMessageResults(1, Map.of());
|
||||
}
|
||||
return method.invoke(delegate, args);
|
||||
});
|
||||
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {
|
||||
});
|
||||
return captured.get();
|
||||
}
|
||||
}
|
||||
415
src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java
Normal file
415
src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java
Normal file
@ -0,0 +1,415 @@
|
||||
package org.asamk.signal.dbus;
|
||||
|
||||
import org.asamk.Signal;
|
||||
import org.asamk.SignalControl;
|
||||
import org.asamk.signal.DbusConfig;
|
||||
import org.asamk.signal.commands.CommandHandler;
|
||||
import org.asamk.signal.commands.ListGroupsCommand;
|
||||
import org.asamk.signal.commands.LocalCommand;
|
||||
import org.asamk.signal.commands.exceptions.UserErrorException;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.api.Group;
|
||||
import org.asamk.signal.manager.api.GroupId;
|
||||
import org.asamk.signal.manager.api.GroupMember;
|
||||
import org.asamk.signal.manager.api.GroupPermission;
|
||||
import org.asamk.signal.manager.api.Identity;
|
||||
import org.asamk.signal.manager.api.MessageEnvelope;
|
||||
import org.asamk.signal.manager.api.Recipient;
|
||||
import org.asamk.signal.manager.api.RecipientAddress;
|
||||
import org.asamk.signal.manager.api.RecipientIdentifier;
|
||||
import org.asamk.signal.manager.api.SendMessageResults;
|
||||
import org.asamk.signal.manager.api.TrustLevel;
|
||||
import org.asamk.signal.manager.api.TypingAction;
|
||||
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
|
||||
import org.freedesktop.dbus.DBusPath;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
|
||||
import org.freedesktop.dbus.exceptions.DBusExecutionException;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class NumberlessDbusTest {
|
||||
|
||||
private static final String NUMBER = "+12025550123";
|
||||
private static final String ACI = "11111111-1111-4111-8111-111111111111";
|
||||
private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222";
|
||||
private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333";
|
||||
private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111";
|
||||
private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123";
|
||||
|
||||
@Test
|
||||
void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() {
|
||||
final var numberless = new TestAccount(null, ACI);
|
||||
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
|
||||
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null);
|
||||
final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath());
|
||||
|
||||
assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)),
|
||||
Set.copyOf(control.listAccounts()));
|
||||
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
|
||||
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
|
||||
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
|
||||
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = NUMBER)
|
||||
void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception {
|
||||
final var account = new TestAccount(number, ACI);
|
||||
final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true);
|
||||
final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
|
||||
|
||||
assertEquals(number == null ? "" : number, signal.getSelfNumber());
|
||||
assertEquals(number, client.getSelfNumber());
|
||||
assertEquals(number == null ? ACI : number, client.getSelfIdentifier());
|
||||
checkSelfOperations(client,
|
||||
account,
|
||||
number == null
|
||||
? new RecipientIdentifier.Uuid(UUID.fromString(ACI))
|
||||
: new RecipientIdentifier.Number(number));
|
||||
}
|
||||
|
||||
// Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
|
||||
@Nested
|
||||
@Timeout(20)
|
||||
@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+")
|
||||
class SessionBus {
|
||||
|
||||
@Test
|
||||
void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception {
|
||||
final var numberless = new TestAccount(null, ACI);
|
||||
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
|
||||
final var secondNumberless = new TestAccount(null, THIRD_ACI);
|
||||
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager,
|
||||
numbered.manager,
|
||||
secondNumberless.manager), null);
|
||||
final var busname = newBusname();
|
||||
try (final var server = new DbusHandler(false,
|
||||
busname,
|
||||
accounts,
|
||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
||||
server.init();
|
||||
final var control = connection.getRemoteObject(busname,
|
||||
DbusConfig.getObjectPath(),
|
||||
SignalControl.class);
|
||||
assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)),
|
||||
control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet()));
|
||||
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
|
||||
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
|
||||
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
|
||||
assertEquals(ACI, selectAccount(ACI, connection, busname));
|
||||
assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname));
|
||||
assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname));
|
||||
assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname));
|
||||
|
||||
final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
|
||||
assertEquals(3, client.getManagers().size());
|
||||
assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
|
||||
assertNull(client.getManager(ACI).getSelfNumber());
|
||||
assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());
|
||||
|
||||
final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
|
||||
numberless.manager.close();
|
||||
assertEquals(2, control.listAccounts().size());
|
||||
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI));
|
||||
assertThrows(DBusExecutionException.class, removed::getSelfACI);
|
||||
assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI());
|
||||
assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = NUMBER)
|
||||
void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception {
|
||||
final var account = new TestAccount(number, ACI);
|
||||
final var busname = newBusname();
|
||||
try (final var server = new DbusHandler(false,
|
||||
busname,
|
||||
account.manager,
|
||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
||||
server.init();
|
||||
assertEquals(ACI, selectAccount(null, connection, busname));
|
||||
assertEquals(ACI, selectAccount(ACI, connection, busname));
|
||||
if (number != null) {
|
||||
assertEquals(ACI, selectAccount(number, connection, busname));
|
||||
}
|
||||
assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception {
|
||||
final var account = new TestAccount(null, ACI);
|
||||
final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI));
|
||||
final var numberedContact = new RecipientAddress(NUMBER);
|
||||
account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(),
|
||||
Recipient.newBuilder().withAddress(numberedContact).build());
|
||||
account.identities = List.of(new Identity(contact,
|
||||
new byte[]{1},
|
||||
"test",
|
||||
new byte[]{2},
|
||||
TrustLevel.TRUSTED_UNVERIFIED,
|
||||
123));
|
||||
final var groupId = GroupId.unknownVersion(new byte[32]);
|
||||
account.groups = List.of(new Group(groupId,
|
||||
"Test group",
|
||||
"",
|
||||
null,
|
||||
Set.of(new GroupMember(contact, true, null, null),
|
||||
new GroupMember(numberedContact, false, null, null)),
|
||||
Set.of(contact),
|
||||
Set.of(contact),
|
||||
Set.of(contact),
|
||||
false,
|
||||
0,
|
||||
GroupPermission.EVERY_MEMBER,
|
||||
GroupPermission.EVERY_MEMBER,
|
||||
GroupPermission.EVERY_MEMBER,
|
||||
true,
|
||||
true,
|
||||
false));
|
||||
final var busname = newBusname();
|
||||
try (final var server = new DbusHandler(false,
|
||||
busname,
|
||||
account.manager,
|
||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
||||
server.init();
|
||||
final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
|
||||
assertEquals(List.of(NUMBER), signal.listNumbers());
|
||||
assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers()));
|
||||
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
|
||||
final var contacts = client.getRecipients(false,
|
||||
Optional.empty(),
|
||||
Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
|
||||
Optional.empty());
|
||||
assertEquals(1, contacts.size());
|
||||
assertEquals(contact, contacts.getFirst().getAddress());
|
||||
assertEquals(contact, client.getIdentities().getFirst().recipient());
|
||||
final var group = client.getGroups().getFirst();
|
||||
assertEquals(Set.of(contact, numberedContact),
|
||||
group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet()));
|
||||
assertEquals(Set.of(contact), group.pendingMembers());
|
||||
assertEquals(Set.of(contact), group.requestingMembers());
|
||||
assertEquals(Set.of(contact), group.bannedMembers());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception {
|
||||
final var account = new TestAccount(null, ACI);
|
||||
final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null);
|
||||
final var busname = newBusname();
|
||||
try (final var server = new DbusHandler(false,
|
||||
busname,
|
||||
accounts,
|
||||
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
|
||||
server.init();
|
||||
assertEquals(ACI, selectAccount(null, connection, busname));
|
||||
final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
|
||||
assertEquals("", signal.getSelfNumber());
|
||||
final var groupId = GroupId.unknownVersion(new byte[32]);
|
||||
assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI));
|
||||
assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize()));
|
||||
assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of()));
|
||||
assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
|
||||
account.calls.get(0).arguments().get(1));
|
||||
assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1));
|
||||
assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1));
|
||||
assertThrows(Signal.Error.InvalidNumber.class,
|
||||
() -> signal.sendMessage("Invalid local number", List.of(), "2025550124"));
|
||||
|
||||
account.calls.clear();
|
||||
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
|
||||
checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI)));
|
||||
final var received = new LinkedBlockingQueue<MessageEnvelope>();
|
||||
client.addReceiveHandler((envelope, error) -> received.add(envelope));
|
||||
for (final var group : List.of(Optional.<GroupId>empty(), Optional.of(groupId))) {
|
||||
account.emit(messageEnvelope(group));
|
||||
final var envelope = received.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(envelope);
|
||||
assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
|
||||
assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
|
||||
assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
|
||||
assertEquals(Optional.of(ACI),
|
||||
envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
|
||||
assertEquals(group,
|
||||
envelope.data()
|
||||
.orElseThrow()
|
||||
.groupContext()
|
||||
.map(MessageEnvelope.Data.GroupContext::groupId));
|
||||
}
|
||||
}
|
||||
assertEquals(0, account.receivers.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkSelfOperations(
|
||||
final Manager client,
|
||||
final TestAccount account,
|
||||
final RecipientIdentifier.Single self
|
||||
) throws Exception {
|
||||
final Set<RecipientIdentifier> recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
|
||||
client.sendTypingMessage(TypingAction.START, recipients);
|
||||
client.sendRemoteDeleteMessage(123, recipients);
|
||||
client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
|
||||
assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
|
||||
account.calls.stream().map(Call::name).toList());
|
||||
assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
|
||||
assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
|
||||
assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
|
||||
}
|
||||
|
||||
private static String selectAccount(
|
||||
final String account,
|
||||
final DBusConnection connection,
|
||||
final String busname
|
||||
) throws Exception {
|
||||
final var selected = new AtomicReference<String>();
|
||||
final var handler = new CommandHandler(null, null) {
|
||||
@Override
|
||||
public void handleLocalCommand(final LocalCommand command, final Manager manager) {
|
||||
selected.set(manager.getSelfACI());
|
||||
}
|
||||
};
|
||||
DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
|
||||
return selected.get();
|
||||
}
|
||||
|
||||
private static String newBusname() {
|
||||
return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private static MessageEnvelope messageEnvelope(final Optional<GroupId> groupId) {
|
||||
final var data = new MessageEnvelope.Data(123,
|
||||
groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of("Incoming"),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
|
||||
List.of(),
|
||||
List.of(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
|
||||
1,
|
||||
123,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(data),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private record Call(String name, List<Object> arguments) {}
|
||||
|
||||
private static final class TestAccount {
|
||||
|
||||
private final Manager manager;
|
||||
private final List<Call> calls = new CopyOnWriteArrayList<>();
|
||||
private final List<Runnable> closedListeners = new CopyOnWriteArrayList<>();
|
||||
private final List<Manager.ReceiveMessageHandler> receivers = new CopyOnWriteArrayList<>();
|
||||
private List<Recipient> recipients = List.of();
|
||||
private List<Group> groups = List.of();
|
||||
private List<Identity> identities = List.of();
|
||||
|
||||
private TestAccount(final String number, final String aci) {
|
||||
manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
||||
new Class<?>[]{Manager.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.isDefault()) {
|
||||
return InvocationHandler.invokeDefault(proxy, method, args);
|
||||
}
|
||||
return switch (method.getName()) {
|
||||
case "getSelfNumber" -> number;
|
||||
case "getSelfACI" -> aci;
|
||||
case "getLinkedDevices" -> List.of();
|
||||
case "getGroups" -> groups;
|
||||
case "getGroup" -> groups.getFirst();
|
||||
case "getIdentities" -> identities;
|
||||
case "getRecipients" -> recipients;
|
||||
case "isContactBlocked" -> false;
|
||||
case "getContactOrProfileName" -> "Test contact";
|
||||
case "addAddressChangedListener" -> null;
|
||||
case "addClosedListener" -> {
|
||||
closedListeners.add((Runnable) args[0]);
|
||||
yield null;
|
||||
}
|
||||
case "close" -> {
|
||||
closedListeners.forEach(Runnable::run);
|
||||
closedListeners.clear();
|
||||
yield null;
|
||||
}
|
||||
case "addReceiveHandler" -> {
|
||||
receivers.add((Manager.ReceiveMessageHandler) args[0]);
|
||||
yield null;
|
||||
}
|
||||
case "removeReceiveHandler" -> {
|
||||
receivers.remove(args[0]);
|
||||
yield null;
|
||||
}
|
||||
case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage",
|
||||
"sendMessageReaction" -> {
|
||||
calls.add(new Call(method.getName(), List.of(args)));
|
||||
yield new SendMessageResults(42, Map.of());
|
||||
}
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == args[0];
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private void emit(final MessageEnvelope envelope) {
|
||||
receivers.forEach(receiver -> receiver.handleMessage(envelope, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package org.asamk.signal.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.asamk.signal.commands.ListAccountsCommand;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
class NumberlessAccountOutputTest {
|
||||
|
||||
private static final String ACI = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@Test
|
||||
void receiveEventsIdentifyNumberlessAccountByAci() {
|
||||
final var output = new AtomicReference<Object>();
|
||||
new JsonReceiveMessageHandler(manager(null), output::set).handleMessage(null, null);
|
||||
assertEquals(ACI, ((Map<?, ?>) output.get()).get("account"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void receiveEventsKeepNumberedAccountIdentifiers() {
|
||||
final var output = new AtomicReference<Object>();
|
||||
new JsonReceiveMessageHandler(manager("+12025550123"), output::set).handleMessage(null, null);
|
||||
assertEquals("+12025550123", ((Map<?, ?>) output.get()).get("account"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAccountsIncludesAciWithoutPuttingItInTheNumberField() throws Exception {
|
||||
final var numberless = manager(null);
|
||||
final var numbered = manager("+12025550123");
|
||||
final var multi = new MultiAccountManagerImpl(List.of(numberless, numbered), null);
|
||||
final var output = new AtomicReference<Object>();
|
||||
new ListAccountsCommand().handleCommand(Map.of(), multi, output::set);
|
||||
final var accounts = new ObjectMapper().valueToTree(output.get());
|
||||
assertEquals(2, accounts.size());
|
||||
for (final var account : accounts) {
|
||||
assertEquals(ACI, account.path("aci").asText());
|
||||
assertFalse(ACI.equals(account.path("number").asText()));
|
||||
}
|
||||
assertSame(numbered, multi.getManager("+12025550123"));
|
||||
}
|
||||
|
||||
private static Manager manager(final String number) {
|
||||
return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
|
||||
new Class<?>[]{Manager.class},
|
||||
(proxy, method, args) -> {
|
||||
if (method.isDefault()) {
|
||||
return InvocationHandler.invokeDefault(proxy, method, args);
|
||||
}
|
||||
return switch (method.getName()) {
|
||||
case "getSelfNumber" -> number;
|
||||
case "getSelfACI" -> ACI;
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == args[0];
|
||||
case "addClosedListener" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -72,11 +72,6 @@ class SubscribeCallEventsTest {
|
||||
this.managers = new ArrayList<>(managers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAccountNumbers() {
|
||||
return managers.stream().map(Manager::getSelfNumber).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Manager> getManagers() {
|
||||
return managers;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user