Implement support for usernames

This commit is contained in:
AsamK 2023-04-03 19:00:05 +02:00
parent 03f193b34c
commit 9f60ed534a
18 changed files with 440 additions and 46 deletions

View file

@ -195,6 +195,22 @@
{ {
"name":"org.signal.libsignal.protocol.state.SignedPreKeyStore" "name":"org.signal.libsignal.protocol.state.SignedPreKeyStore"
}, },
{
"name":"org.signal.libsignal.usernames.BadNicknameCharacterException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooShortException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{ {
"name":"org.signal.libsignal.zkgroup.InvalidInputException", "name":"org.signal.libsignal.zkgroup.InvalidInputException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }] "methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]

View file

@ -672,6 +672,13 @@
"queryAllDeclaredConstructors":true, "queryAllDeclaredConstructors":true,
"methods":[{"name":"deviceLinkUri","parameterTypes":[] }] "methods":[{"name":"deviceLinkUri","parameterTypes":[] }]
}, },
{
"name":"org.asamk.signal.commands.UpdateAccountCommand$JsonAccountResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"username","parameterTypes":[] }]
},
{ {
"name":"org.asamk.signal.commands.VerifyCommand$VerifyParams", "name":"org.asamk.signal.commands.VerifyCommand$VerifyParams",
"allDeclaredFields":true, "allDeclaredFields":true,
@ -2535,6 +2542,12 @@
"allDeclaredMethods":true, "allDeclaredMethods":true,
"allDeclaredConstructors":true "allDeclaredConstructors":true
}, },
{
"name":"org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true
},
{ {
"name":"org.whispersystems.signalservice.internal.push.DeviceCode", "name":"org.whispersystems.signalservice.internal.push.DeviceCode",
"allDeclaredFields":true, "allDeclaredFields":true,
@ -2553,6 +2566,13 @@
"allDeclaredMethods":true, "allDeclaredMethods":true,
"allDeclaredConstructors":true "allDeclaredConstructors":true
}, },
{
"name":"org.whispersystems.signalservice.internal.push.GetAciByUsernameResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{ {
"name":"org.whispersystems.signalservice.internal.push.GroupMismatchedDevices", "name":"org.whispersystems.signalservice.internal.push.GroupMismatchedDevices",
"allDeclaredFields":true, "allDeclaredFields":true,
@ -2699,6 +2719,19 @@
{"name":"getSkipDeviceTransfer","parameterTypes":[] } {"name":"getSkipDeviceTransfer","parameterTypes":[] }
] ]
}, },
{
"name":"org.whispersystems.signalservice.internal.push.ReserveUsernameRequest",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true
},
{
"name":"org.whispersystems.signalservice.internal.push.ReserveUsernameResponse",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{ {
"name":"org.whispersystems.signalservice.internal.push.SendGroupMessageResponse", "name":"org.whispersystems.signalservice.internal.push.SendGroupMessageResponse",
"allDeclaredFields":true, "allDeclaredFields":true,

View file

@ -9,6 +9,7 @@ import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -77,6 +78,18 @@ public interface Manager extends Closeable {
*/ */
void updateProfile(UpdateProfile updateProfile) throws IOException; void updateProfile(UpdateProfile updateProfile) throws IOException;
/**
* Set a username for the account.
* If the username is null, it will be deleted.
*/
String setUsername(String username) throws IOException, InvalidUsernameException;
/**
* Set a username for the account.
* If the username is null, it will be deleted.
*/
void deleteUsername() throws IOException;
void unregister() throws IOException; void unregister() throws IOException;
void deleteAccount() throws IOException; void deleteAccount() throws IOException;

View file

@ -25,6 +25,7 @@ import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidStickerException; import org.asamk.signal.manager.api.InvalidStickerException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -65,6 +66,7 @@ import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.StickerUtils; import org.asamk.signal.manager.util.StickerUtils;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.SignalSessionLock;
@ -290,6 +292,20 @@ class ManagerImpl implements Manager {
context.getSyncHelper().sendSyncFetchProfileMessage(); context.getSyncHelper().sendSyncFetchProfileMessage();
} }
@Override
public String setUsername(final String username) throws IOException, InvalidUsernameException {
try {
return context.getAccountHelper().reserveUsername(username);
} catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
}
}
@Override
public void deleteUsername() throws IOException {
context.getAccountHelper().deleteUsername();
}
@Override @Override
public void unregister() throws IOException { public void unregister() throws IOException {
context.getAccountHelper().unregister(); context.getAccountHelper().unregister();
@ -737,13 +753,18 @@ class ManagerImpl implements Manager {
@Override @Override
public void deleteRecipient(final RecipientIdentifier.Single recipient) { public void deleteRecipient(final RecipientIdentifier.Single recipient) {
account.removeRecipient(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier())); final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
if (recipientIdOptional.isPresent()) {
account.removeRecipient(recipientIdOptional.get());
}
} }
@Override @Override
public void deleteContact(final RecipientIdentifier.Single recipient) { public void deleteContact(final RecipientIdentifier.Single recipient) {
account.getContactStore() final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
.deleteContact(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier())); if (recipientIdOptional.isPresent()) {
account.getContactStore().deleteContact(recipientIdOptional.get());
}
} }
@Override @Override

View file

@ -0,0 +1,12 @@
package org.asamk.signal.manager.api;
public class InvalidUsernameException extends Exception {
public InvalidUsernameException(final String message) {
super(message);
}
public InvalidUsernameException(final String message, final Throwable cause) {
super(message, cause);
}
}

View file

@ -6,7 +6,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) { public record RecipientAddress(Optional<UUID> uuid, Optional<String> number, Optional<String> username) {
public static final UUID UNKNOWN_UUID = ServiceId.UNKNOWN.uuid(); public static final UUID UNKNOWN_UUID = ServiceId.UNKNOWN.uuid();
@ -18,21 +18,25 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
*/ */
public RecipientAddress { public RecipientAddress {
uuid = uuid.isPresent() && uuid.get().equals(UNKNOWN_UUID) ? Optional.empty() : uuid; uuid = uuid.isPresent() && uuid.get().equals(UNKNOWN_UUID) ? Optional.empty() : uuid;
if (uuid.isEmpty() && number.isEmpty()) { if (uuid.isEmpty() && number.isEmpty() && username.isEmpty()) {
throw new AssertionError("Must have either a UUID or E164 number!"); throw new AssertionError("Must have either a UUID or E164 number!");
} }
} }
public RecipientAddress(UUID uuid, String e164) { public RecipientAddress(UUID uuid, String e164) {
this(Optional.ofNullable(uuid), Optional.ofNullable(e164)); this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(UUID uuid, String e164, String username) {
this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.ofNullable(username));
} }
public RecipientAddress(SignalServiceAddress address) { public RecipientAddress(SignalServiceAddress address) {
this(Optional.of(address.getServiceId().uuid()), address.getNumber()); this(Optional.of(address.getServiceId().uuid()), address.getNumber(), Optional.empty());
} }
public RecipientAddress(UUID uuid) { public RecipientAddress(UUID uuid) {
this(Optional.of(uuid), Optional.empty()); this(Optional.of(uuid), Optional.empty(), Optional.empty());
} }
public ServiceId getServiceId() { public ServiceId getServiceId() {
@ -44,6 +48,8 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
return uuid.get().toString(); return uuid.get().toString();
} else if (number.isPresent()) { } else if (number.isPresent()) {
return number.get(); return number.get();
} else if (username.isPresent()) {
return username.get();
} else { } else {
throw new AssertionError("Given the checks in the constructor, this should not be possible."); throw new AssertionError("Given the checks in the constructor, this should not be possible.");
} }
@ -54,14 +60,16 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
return number.get(); return number.get();
} else if (uuid.isPresent()) { } else if (uuid.isPresent()) {
return uuid.get().toString(); return uuid.get().toString();
} else if (username.isPresent()) {
return username.get();
} else { } else {
throw new AssertionError("Given the checks in the constructor, this should not be possible."); throw new AssertionError("Given the checks in the constructor, this should not be possible.");
} }
} }
public boolean matches(RecipientAddress other) { public boolean matches(RecipientAddress other) {
return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || ( return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get()))
number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()) || (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()))
); || (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get()));
} }
} }

View file

@ -30,6 +30,10 @@ public sealed interface RecipientIdentifier {
return new Uuid(UUID.fromString(identifier)); return new Uuid(UUID.fromString(identifier));
} }
if (identifier.startsWith("u:")) {
return new Username(identifier.substring(2));
}
final var normalizedNumber = PhoneNumberFormatter.formatNumber(identifier, localNumber); final var normalizedNumber = PhoneNumberFormatter.formatNumber(identifier, localNumber);
if (!normalizedNumber.equals(identifier)) { if (!normalizedNumber.equals(identifier)) {
final Logger logger = LoggerFactory.getLogger(RecipientIdentifier.class); final Logger logger = LoggerFactory.getLogger(RecipientIdentifier.class);
@ -46,6 +50,8 @@ public sealed interface RecipientIdentifier {
return new Number(address.number().get()); return new Number(address.number().get());
} else if (address.uuid().isPresent()) { } else if (address.uuid().isPresent()) {
return new Uuid(address.uuid().get()); return new Uuid(address.uuid().get());
} else if (address.username().isPresent()) {
return new Username(address.username().get());
} }
throw new AssertionError("RecipientAddress without identifier"); throw new AssertionError("RecipientAddress without identifier");
} }
@ -79,6 +85,19 @@ public sealed interface RecipientIdentifier {
} }
} }
record Username(String username) implements Single {
@Override
public String getIdentifier() {
return "u:" + username;
}
@Override
public RecipientAddress toPartialRecipientAddress() {
return new RecipientAddress(null, null, username);
}
}
record Group(GroupId groupId) implements RecipientIdentifier { record Group(GroupId groupId) implements RecipientIdentifier {
@Override @Override

View file

@ -15,6 +15,8 @@ import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord; import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
@ -27,13 +29,18 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
public class AccountHelper { public class AccountHelper {
private final static Logger logger = LoggerFactory.getLogger(AccountHelper.class); private final static Logger logger = LoggerFactory.getLogger(AccountHelper.class);
@ -173,6 +180,83 @@ public class AccountHelper {
updateSelfIdentifiers(newNumber, account.getAci(), PNI.parseOrThrow(result.first().getPni())); updateSelfIdentifiers(newNumber, account.getAci(), PNI.parseOrThrow(result.first().getPni()));
} }
public static final int USERNAME_MIN_LENGTH = 3;
public static final int USERNAME_MAX_LENGTH = 32;
public String reserveUsername(String nickname) throws IOException, BaseUsernameException {
final var currentUsername = account.getUsername();
if (currentUsername != null) {
final var currentNickname = currentUsername.substring(0, currentUsername.indexOf('.'));
if (currentNickname.equals(nickname)) {
refreshCurrentUsername();
return currentUsername;
}
}
final var candidates = Username.generateCandidates(nickname, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH);
final var candidateHashes = new ArrayList<String>();
for (final var candidate : candidates) {
candidateHashes.add(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(candidate)));
}
final var response = dependencies.getAccountManager().reserveUsername(candidateHashes);
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash());
if (hashIndex == -1) {
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
throw new IOException("Unexpected username response");
}
logger.debug("[reserveUsername] Successfully reserved username.");
final var username = candidates.get(hashIndex);
dependencies.getAccountManager().confirmUsername(username, response);
account.setUsername(username);
account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
logger.debug("[confirmUsername] Successfully confirmed username.");
return username;
}
public void refreshCurrentUsername() throws IOException, BaseUsernameException {
final var localUsername = account.getUsername();
if (localUsername == null) {
return;
}
final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI();
final var serverUsernameHash = whoAmIResponse.getUsernameHash();
final var hasServerUsername = !isEmpty(serverUsernameHash);
final var localUsernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(localUsername));
if (!hasServerUsername) {
logger.debug("No remote username is set.");
}
if (!Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Local username hash does not match server username hash.");
}
if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Attempting to resynchronize username.");
tryReserveConfirmUsername(localUsername, localUsernameHash);
} else {
logger.debug("Username already set, not refreshing.");
}
}
private void tryReserveConfirmUsername(final String username, String localUsernameHash) throws IOException {
final var response = dependencies.getAccountManager().reserveUsername(List.of(localUsernameHash));
logger.debug("[reserveUsername] Successfully reserved existing username.");
dependencies.getAccountManager().confirmUsername(username, response);
logger.debug("[confirmUsername] Successfully confirmed existing username.");
}
public void deleteUsername() throws IOException {
dependencies.getAccountManager().deleteUsername();
account.setUsername(null);
logger.debug("[deleteUsername] Successfully deleted the username.");
}
public void setDeviceName(String deviceName) { public void setDeviceName(String deviceName) {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey(); final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);

View file

@ -6,6 +6,8 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.ACI;
@ -13,6 +15,7 @@ import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.services.CdsiV2Service; import org.whispersystems.signalservice.api.services.CdsiV2Service;
import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException; import java.io.IOException;
import java.util.Collection; import java.util.Collection;
@ -47,7 +50,7 @@ public class RecipientHelper {
final var number = address.number().get(); final var number = address.number().get();
final ServiceId serviceId; final ServiceId serviceId;
try { try {
serviceId = getRegisteredUser(number); serviceId = getRegisteredUserByNumber(number);
} catch (UnregisteredRecipientException e) { } catch (UnregisteredRecipientException e) {
logger.warn("Failed to get uuid for e164 number: {}", number); logger.warn("Failed to get uuid for e164 number: {}", number);
// Return SignalServiceAddress with unknown UUID // Return SignalServiceAddress with unknown UUID
@ -78,15 +81,33 @@ public class RecipientHelper {
public RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredRecipientException { public RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredRecipientException {
if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) { if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) {
return account.getRecipientResolver().resolveRecipient(ServiceId.from(uuidRecipient.uuid())); return account.getRecipientResolver().resolveRecipient(ServiceId.from(uuidRecipient.uuid()));
} else { } else if (recipient instanceof RecipientIdentifier.Number numberRecipient) {
final var number = ((RecipientIdentifier.Number) recipient).number(); final var number = numberRecipient.number();
return account.getRecipientStore().resolveRecipient(number, () -> { return account.getRecipientStore().resolveRecipientByNumber(number, () -> {
try { try {
return getRegisteredUser(number); return getRegisteredUserByNumber(number);
} catch (Exception e) { } catch (Exception e) {
return null; return null;
} }
}); });
} else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) {
final var username = usernameRecipient.username();
return account.getRecipientStore().resolveRecipientByUsername(username, () -> {
try {
return getRegisteredUserByUsername(username);
} catch (Exception e) {
return null;
}
});
}
throw new AssertionError("Unexpected RecipientIdentifier: " + recipient);
}
public Optional<RecipientId> resolveRecipientOptional(final RecipientIdentifier.Single recipient) {
try {
return Optional.of(resolveRecipient(recipient));
} catch (UnregisteredRecipientException e) {
return Optional.empty();
} }
} }
@ -96,7 +117,7 @@ public class RecipientHelper {
return recipientId; return recipientId;
} }
final var number = address.getNumber().get(); final var number = address.getNumber().get();
final var serviceId = getRegisteredUser(number); final var serviceId = getRegisteredUserByNumber(number);
return account.getRecipientTrustedResolver() return account.getRecipientTrustedResolver()
.resolveRecipientTrusted(new SignalServiceAddress(serviceId, number)); .resolveRecipientTrusted(new SignalServiceAddress(serviceId, number));
} }
@ -111,7 +132,7 @@ public class RecipientHelper {
return registeredUsers; return registeredUsers;
} }
private ServiceId getRegisteredUser(final String number) throws IOException, UnregisteredRecipientException { private ServiceId getRegisteredUserByNumber(final String number) throws IOException, UnregisteredRecipientException {
final Map<String, RegisteredUser> aciMap; final Map<String, RegisteredUser> aciMap;
try { try {
aciMap = getRegisteredUsers(Set.of(number)); aciMap = getRegisteredUsers(Set.of(number));
@ -153,8 +174,9 @@ public class RecipientHelper {
return registeredUsers; return registeredUsers;
} }
private ACI getRegisteredUserByUsername(String username) throws IOException { private ACI getRegisteredUserByUsername(String username) throws IOException, BaseUsernameException {
return dependencies.getAccountManager().getAciByUsernameHash(username); return dependencies.getAccountManager()
.getAciByUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
} }
public record RegisteredUser(Optional<ACI> aci, Optional<PNI> pni) { public record RegisteredUser(Optional<ACI> aci, Optional<PNI> pni) {

View file

@ -102,7 +102,11 @@ public class StorageHelper {
final var contactRecord = record.getContact().get(); final var contactRecord = record.getContact().get();
final var address = new RecipientAddress(contactRecord.getServiceId(), contactRecord.getNumber().orElse(null)); final var address = new RecipientAddress(contactRecord.getServiceId(), contactRecord.getNumber().orElse(null));
final var recipientId = account.getRecipientResolver().resolveRecipient(address); var recipientId = account.getRecipientResolver().resolveRecipient(address);
if (contactRecord.getUsername().isPresent()) {
recipientId = account.getRecipientTrustedResolver()
.resolveRecipientTrusted(contactRecord.getServiceId(), contactRecord.getUsername().get());
}
final var contact = account.getContactStore().getContact(recipientId); final var contact = account.getContactStore().getContact(recipientId);
final var blocked = contact != null && contact.isBlocked(); final var blocked = contact != null && contact.isBlocked();
@ -257,6 +261,7 @@ public class StorageHelper {
}); });
} }
account.getConfigurationStore().setPhoneNumberUnlisted(accountRecord.isPhoneNumberUnlisted()); account.getConfigurationStore().setPhoneNumberUnlisted(accountRecord.isPhoneNumberUnlisted());
account.setUsername(accountRecord.getUsername());
if (accountRecord.getProfileKey().isPresent()) { if (accountRecord.getProfileKey().isPresent()) {
ProfileKey profileKey; ProfileKey profileKey;

View file

@ -22,7 +22,7 @@ import java.sql.SQLException;
public class AccountDatabase extends Database { public class AccountDatabase extends Database {
private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class); private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 11; private static final long DATABASE_VERSION = 12;
private AccountDatabase(final HikariDataSource dataSource) { private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource); super(logger, DATABASE_VERSION, dataSource);
@ -296,5 +296,13 @@ public class AccountDatabase extends Database {
"""); """);
} }
} }
if (oldVersion < 12) {
logger.debug("Updating database: Adding username field");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD COLUMN username TEXT;
""");
}
}
} }
} }

View file

@ -120,6 +120,7 @@ public class SignalAccount implements Closeable {
private String accountPath; private String accountPath;
private ServiceEnvironment serviceEnvironment; private ServiceEnvironment serviceEnvironment;
private String number; private String number;
private String username;
private ACI aci; private ACI aci;
private PNI pni; private PNI pni;
private String sessionId; private String sessionId;
@ -542,6 +543,9 @@ public class SignalAccount implements Closeable {
serviceEnvironment = ServiceEnvironment.valueOf(rootNode.get("serviceEnvironment").asText()); serviceEnvironment = ServiceEnvironment.valueOf(rootNode.get("serviceEnvironment").asText());
} }
registered = Utils.getNotNullNode(rootNode, "registered").asBoolean(); registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
if (rootNode.hasNonNull("usernameIdentifier")) {
username = rootNode.get("usernameIdentifier").asText();
}
if (rootNode.hasNonNull("uuid")) { if (rootNode.hasNonNull("uuid")) {
try { try {
aci = ACI.parseOrThrow(rootNode.get("uuid").asText()); aci = ACI.parseOrThrow(rootNode.get("uuid").asText());
@ -935,6 +939,7 @@ public class SignalAccount implements Closeable {
rootNode.put("version", CURRENT_STORAGE_VERSION) rootNode.put("version", CURRENT_STORAGE_VERSION)
.put("username", number) .put("username", number)
.put("serviceEnvironment", serviceEnvironment == null ? null : serviceEnvironment.name()) .put("serviceEnvironment", serviceEnvironment == null ? null : serviceEnvironment.name())
.put("usernameIdentifier", username)
.put("uuid", aci == null ? null : aci.toString()) .put("uuid", aci == null ? null : aci.toString())
.put("pni", pni == null ? null : pni.toString()) .put("pni", pni == null ? null : pni.toString())
.put("sessionId", sessionId) .put("sessionId", sessionId)
@ -1297,6 +1302,15 @@ public class SignalAccount implements Closeable {
save(); save();
} }
public String getUsername() {
return username;
}
public void setUsername(final String username) {
this.username = username;
save();
}
public ServiceEnvironment getServiceEnvironment() { public ServiceEnvironment getServiceEnvironment() {
return serviceEnvironment; return serviceEnvironment;
} }
@ -1368,7 +1382,7 @@ public class SignalAccount implements Closeable {
} }
public RecipientAddress getSelfRecipientAddress() { public RecipientAddress getSelfRecipientAddress() {
return new RecipientAddress(aci, pni, number); return new RecipientAddress(aci, pni, number, username);
} }
public RecipientId getSelfRecipientId() { public RecipientId getSelfRecipientId() {

View file

@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional; import java.util.Optional;
public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni, Optional<String> number) { public record RecipientAddress(
Optional<ServiceId> serviceId, Optional<PNI> pni, Optional<String> number, Optional<String> username
) {
/** /**
* Construct a RecipientAddress. * Construct a RecipientAddress.
@ -38,23 +40,30 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
} }
public RecipientAddress(Optional<ServiceId> serviceId, Optional<String> number) { public RecipientAddress(Optional<ServiceId> serviceId, Optional<String> number) {
this(serviceId, Optional.empty(), number); this(serviceId, Optional.empty(), number, Optional.empty());
} }
public RecipientAddress(ServiceId serviceId, String e164) { public RecipientAddress(ServiceId serviceId, String e164) {
this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164)); this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164), Optional.empty());
} }
public RecipientAddress(ServiceId serviceId, PNI pni, String e164) { public RecipientAddress(ServiceId serviceId, PNI pni, String e164) {
this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164)); this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164), Optional.empty());
}
public RecipientAddress(ServiceId serviceId, PNI pni, String e164, String username) {
this(Optional.ofNullable(serviceId),
Optional.ofNullable(pni),
Optional.ofNullable(e164),
Optional.ofNullable(username));
} }
public RecipientAddress(SignalServiceAddress address) { public RecipientAddress(SignalServiceAddress address) {
this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber()); this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber(), Optional.empty());
} }
public RecipientAddress(org.asamk.signal.manager.api.RecipientAddress address) { public RecipientAddress(org.asamk.signal.manager.api.RecipientAddress address) {
this(address.uuid().map(ServiceId::from), Optional.empty(), address.number()); this(address.uuid().map(ServiceId::from), Optional.empty(), address.number(), address.username());
} }
public RecipientAddress(ServiceId serviceId) { public RecipientAddress(ServiceId serviceId) {
@ -66,7 +75,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni) this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni)
) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId, ) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId,
address.pni.or(this::pni), address.pni.or(this::pni),
address.number.or(this::number)); address.number.or(this::number),
address.username.or(this::username));
} }
public RecipientAddress removeIdentifiersFrom(RecipientAddress address) { public RecipientAddress removeIdentifiersFrom(RecipientAddress address) {
@ -74,7 +84,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
? Optional.empty() ? Optional.empty()
: this.serviceId, : this.serviceId,
address.pni.equals(this.pni) || address.serviceId.equals(this.pni) ? Optional.empty() : this.pni, address.pni.equals(this.pni) || address.serviceId.equals(this.pni) ? Optional.empty() : this.pni,
address.number.equals(this.number) ? Optional.empty() : this.number); address.number.equals(this.number) ? Optional.empty() : this.number,
address.username.equals(this.username) ? Optional.empty() : this.username);
} }
public ServiceId getServiceId() { public ServiceId getServiceId() {
@ -118,13 +129,17 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
} }
public boolean hasSingleIdentifier() { public boolean hasSingleIdentifier() {
return serviceId().isEmpty() || number.isEmpty(); final var identifiersCount = serviceId().map(s -> 1).orElse(0)
+ number().map(s -> 1).orElse(0)
+ username().map(s -> 1).orElse(0);
return identifiersCount == 1;
} }
public boolean hasIdentifiersOf(RecipientAddress address) { public boolean hasIdentifiersOf(RecipientAddress address) {
return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.equals(pni)) return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.equals(pni))
&& (address.pni.isEmpty() || address.pni.equals(pni)) && (address.pni.isEmpty() || address.pni.equals(pni))
&& (address.number.isEmpty() || address.number.equals(number)); && (address.number.isEmpty() || address.number.equals(number))
&& (address.username.isEmpty() || address.username.equals(username));
} }
public boolean hasAdditionalIdentifiersThan(RecipientAddress address) { public boolean hasAdditionalIdentifiersThan(RecipientAddress address) {
@ -142,6 +157,10 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
number.isPresent() && ( number.isPresent() && (
address.number.isEmpty() || !address.number.equals(number) address.number.isEmpty() || !address.number.equals(number)
) )
) || (
username.isPresent() && (
address.username.isEmpty() || !address.username.equals(username)
)
); );
} }
@ -158,6 +177,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
} }
public org.asamk.signal.manager.api.RecipientAddress toApiRecipientAddress() { public org.asamk.signal.manager.api.RecipientAddress toApiRecipientAddress() {
return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid), number()); return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid),
number(),
username());
} }
} }

View file

@ -52,6 +52,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
CREATE TABLE recipient ( CREATE TABLE recipient (
_id INTEGER PRIMARY KEY AUTOINCREMENT, _id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT UNIQUE, number TEXT UNIQUE,
username TEXT UNIQUE,
uuid BLOB UNIQUE, uuid BLOB UNIQUE,
pni BLOB UNIQUE, pni BLOB UNIQUE,
profile_key BLOB, profile_key BLOB,
@ -93,7 +94,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public RecipientAddress resolveRecipientAddress(RecipientId recipientId) { public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
final var sql = ( final var sql = (
""" """
SELECT r.number, r.uuid, r.pni SELECT r.number, r.uuid, r.pni, r.username
FROM %s r FROM %s r
WHERE r._id = ? WHERE r._id = ?
""" """
@ -193,7 +194,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
return new RecipientId(recipientId, this); return new RecipientId(recipientId, this);
} }
public RecipientId resolveRecipient( public RecipientId resolveRecipientByNumber(
final String number, Supplier<ServiceId> serviceIdSupplier final String number, Supplier<ServiceId> serviceIdSupplier
) throws UnregisteredRecipientException { ) throws UnregisteredRecipientException {
final Optional<RecipientWithAddress> byNumber; final Optional<RecipientWithAddress> byNumber;
@ -214,6 +215,28 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
return byNumber.get().id(); return byNumber.get().id();
} }
public RecipientId resolveRecipientByUsername(
final String username, Supplier<ServiceId> serviceIdSupplier
) throws UnregisteredRecipientException {
final Optional<RecipientWithAddress> byUsername;
try (final var connection = database.getConnection()) {
byUsername = findByUsername(connection, username);
} catch (SQLException e) {
throw new RuntimeException("Failed read from recipient store", e);
}
if (byUsername.isEmpty() || byUsername.get().address().serviceId().isEmpty()) {
final var serviceId = serviceIdSupplier.get();
if (serviceId == null) {
throw new UnregisteredRecipientException(new org.asamk.signal.manager.api.RecipientAddress(null,
null,
username));
}
return resolveRecipient(serviceId);
}
return byUsername.get().id();
}
public RecipientId resolveRecipient(RecipientAddress address) { public RecipientId resolveRecipient(RecipientAddress address) {
synchronized (recipientsLock) { synchronized (recipientsLock) {
final RecipientId recipientId; final RecipientId recipientId;
@ -247,7 +270,21 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Optional<ACI> aci, final Optional<PNI> pni, final Optional<String> number final Optional<ACI> aci, final Optional<PNI> pni, final Optional<String> number
) { ) {
final var serviceId = aci.map(a -> (ServiceId) a).or(() -> pni); final var serviceId = aci.map(a -> (ServiceId) a).or(() -> pni);
return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number), false); return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number, Optional.empty()), false);
}
@Override
public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) {
return resolveRecipientTrusted(new RecipientAddress(serviceId, null, null, username), false);
}
public RecipientId resolveRecipientTrusted(
final ACI aci, final String username
) {
return resolveRecipientTrusted(new RecipientAddress(Optional.of(aci),
Optional.empty(),
Optional.empty(),
Optional.of(username)), false);
} }
@Override @Override
@ -309,7 +346,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = ( final var sql = (
""" """
SELECT r._id, SELECT r._id,
r.number, r.uuid, r.pni, r.number, r.uuid, r.pni, r.username,
r.profile_key, r.profile_key_credential, r.profile_key, r.profile_key_credential,
r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived, r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities
@ -739,7 +776,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = ( final var sql = (
""" """
UPDATE %s UPDATE %s
SET number = ?, uuid = ?, pni = ? SET number = ?, uuid = ?, pni = ?, username = ?
WHERE _id = ? WHERE _id = ?
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
@ -747,7 +784,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setString(1, address.number().orElse(null)); statement.setString(1, address.number().orElse(null));
statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setLong(4, recipientId.id()); statement.setString(4, address.username().orElse(null));
statement.setLong(5, recipientId.id());
statement.executeUpdate(); statement.executeUpdate();
} }
} }
@ -800,7 +838,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final String number final Connection connection, final String number
) throws SQLException { ) throws SQLException {
final var sql = """ final var sql = """
SELECT r._id, r.number, r.uuid, r.pni SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r FROM %s r
WHERE r.number = ? WHERE r.number = ?
LIMIT 1 LIMIT 1
@ -811,11 +849,26 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
} }
} }
private Optional<RecipientWithAddress> findByUsername(
final Connection connection, final String username
) throws SQLException {
final var sql = """
SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r
WHERE r.username = ?
LIMIT 1
""".formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setString(1, username);
return Utils.executeQueryForOptional(statement, this::getRecipientWithAddressFromResultSet);
}
}
private Optional<RecipientWithAddress> findByServiceId( private Optional<RecipientWithAddress> findByServiceId(
final Connection connection, final ServiceId serviceId final Connection connection, final ServiceId serviceId
) throws SQLException { ) throws SQLException {
final var sql = """ final var sql = """
SELECT r._id, r.number, r.uuid, r.pni SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r FROM %s r
WHERE r.uuid = ? OR r.pni = ? WHERE r.uuid = ? OR r.pni = ?
LIMIT 1 LIMIT 1
@ -830,16 +883,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final Connection connection, final RecipientAddress address final Connection connection, final RecipientAddress address
) throws SQLException { ) throws SQLException {
final var sql = """ final var sql = """
SELECT r._id, r.number, r.uuid, r.pni SELECT r._id, r.number, r.uuid, r.pni, r.username
FROM %s r FROM %s r
WHERE r.uuid = ?1 OR r.pni = ?1 OR WHERE r.uuid = ?1 OR r.pni = ?1 OR
r.uuid = ?2 OR r.pni = ?2 OR r.uuid = ?2 OR r.pni = ?2 OR
r.number = ?3 r.number = ?3 OR
r.username = ?4
""".formatted(TABLE_RECIPIENT); """.formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(1, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setBytes(2, address.pni().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(2, address.pni().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null));
statement.setString(3, address.number().orElse(null)); statement.setString(3, address.number().orElse(null));
statement.setString(4, address.username().orElse(null));
return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet) return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
@ -908,7 +963,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var serviceId = Optional.ofNullable(resultSet.getBytes("uuid")).map(ServiceId::parseOrNull); final var serviceId = Optional.ofNullable(resultSet.getBytes("uuid")).map(ServiceId::parseOrNull);
final var pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull); final var pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull);
final var number = Optional.ofNullable(resultSet.getString("number")); final var number = Optional.ofNullable(resultSet.getString("number"));
return new RecipientAddress(serviceId, pni, number); final var username = Optional.ofNullable(resultSet.getString("username"));
return new RecipientAddress(serviceId, pni, number, username);
} }
private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException { private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException {

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.recipients;
import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.PNI; import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Optional; import java.util.Optional;
@ -15,6 +16,8 @@ public interface RecipientTrustedResolver {
RecipientId resolveRecipientTrusted(Optional<ACI> aci, Optional<PNI> pni, Optional<String> number); RecipientId resolveRecipientTrusted(Optional<ACI> aci, Optional<PNI> pni, Optional<String> number);
RecipientId resolveRecipientTrusted(ServiceId serviceId, String username);
class RecipientTrustedResolverWrapper implements RecipientTrustedResolver { class RecipientTrustedResolverWrapper implements RecipientTrustedResolver {
private final Supplier<RecipientTrustedResolver> recipientTrustedResolverSupplier; private final Supplier<RecipientTrustedResolver> recipientTrustedResolverSupplier;
@ -39,5 +42,10 @@ public interface RecipientTrustedResolver {
) { ) {
return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(aci, pni, number); return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(aci, pni, number);
} }
@Override
public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) {
return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(serviceId, username);
}
} }
} }

View file

@ -55,10 +55,12 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
for (var r : recipients) { for (var r : recipients) {
final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact(); final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
final var profile = r.getProfile() == null ? Profile.newBuilder().build() : r.getProfile(); final var profile = r.getProfile() == null ? Profile.newBuilder().build() : r.getProfile();
writer.println("Number: {} Name: {} Profile name: {} Color: {} Blocked: {} Message expiration: {}", writer.println(
"Number: {} Name: {} Profile name: {} Username: {} Color: {} Blocked: {} Message expiration: {}",
r.getAddress().getLegacyIdentifier(), r.getAddress().getLegacyIdentifier(),
contact.getName(), contact.getName(),
profile.getDisplayName(), profile.getDisplayName(),
r.getAddress().username().orElse(""),
contact.getColor(), contact.getColor(),
contact.isBlocked(), contact.isBlocked(),
contact.getMessageExpirationTime() == 0 contact.getMessageExpirationTime() == 0
@ -72,6 +74,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact(); final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
return new JsonContact(address.number().orElse(null), return new JsonContact(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null), address.uuid().map(UUID::toString).orElse(null),
address.username().orElse(null),
contact.getName(), contact.getName(),
contact.getColor(), contact.getColor(),
contact.isBlocked(), contact.isBlocked(),
@ -96,6 +99,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
private record JsonContact( private record JsonContact(
String number, String number,
String uuid, String uuid,
String username,
String name, String name,
String color, String color,
boolean isBlocked, boolean isBlocked,

View file

@ -1,12 +1,17 @@
package org.asamk.signal.commands; package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser; import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException; import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter; import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter;
import java.io.IOException; import java.io.IOException;
@ -21,6 +26,11 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
public void attachToSubparser(final Subparser subparser) { public void attachToSubparser(final Subparser subparser) {
subparser.help("Update the account attributes on the signal server."); subparser.help("Update the account attributes on the signal server.");
subparser.addArgument("-n", "--device-name").help("Specify a name to describe this device."); subparser.addArgument("-n", "--device-name").help("Specify a name to describe this device.");
var mut = subparser.addMutuallyExclusiveGroup();
mut.addArgument("-u", "--username").help("Specify a username that can then be used to contact this account.");
mut.addArgument("--delete-username")
.action(Arguments.storeTrue())
.help("Delete the username associated with this account.");
} }
@Override @Override
@ -33,5 +43,34 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
} catch (IOException e) { } catch (IOException e) {
throw new IOErrorException("UpdateAccount error: " + e.getMessage(), e); throw new IOErrorException("UpdateAccount error: " + e.getMessage(), e);
} }
var username = ns.getString("username");
if (username != null) {
try {
final var newUsername = m.setUsername(username);
if (outputWriter instanceof PlainTextWriter w) {
w.println("Your new username: {}", newUsername);
} else if (outputWriter instanceof JsonWriter w) {
w.write(new JsonAccountResponse(newUsername));
}
} catch (IOException e) {
throw new IOErrorException("Failed to set username: " + e.getMessage(), e);
} catch (InvalidUsernameException e) {
throw new UserErrorException("Invalid username: " + e.getMessage(), e);
} }
} }
var deleteUsername = Boolean.TRUE.equals(ns.getBoolean("delete-username"));
if (deleteUsername) {
try {
m.deleteUsername();
} catch (IOException e) {
throw new IOErrorException("Failed to delete username: " + e.getMessage(), e);
}
}
}
private record JsonAccountResponse(
String username
) {}
}

View file

@ -10,6 +10,7 @@ import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InactiveGroupLinkException;
import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException;
import org.asamk.signal.manager.api.InvalidUsernameException;
import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.NotPrimaryDeviceException; import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@ -151,6 +152,16 @@ public class DbusManagerImpl implements Manager {
updateProfile.isDeleteAvatar()); updateProfile.isDeleteAvatar());
} }
@Override
public String setUsername(final String username) throws IOException, InvalidUsernameException {
throw new UnsupportedOperationException();
}
@Override
public void deleteUsername() throws IOException {
throw new UnsupportedOperationException();
}
@Override @Override
public void unregister() throws IOException { public void unregister() throws IOException {
signal.unregister(); signal.unregister();