]> nmode's Git Repositories - signal-cli/commitdiff
Implement support for usernames
authorAsamK <asamk@gmx.de>
Mon, 3 Apr 2023 17:00:05 +0000 (19:00 +0200)
committerAsamK <asamk@gmx.de>
Mon, 3 Apr 2023 17:00:05 +0000 (19:00 +0200)
18 files changed:
graalvm-config-dir/jni-config.json
graalvm-config-dir/reflect-config.json
lib/src/main/java/org/asamk/signal/manager/Manager.java
lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java
lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java [new file with mode: 0644]
lib/src/main/java/org/asamk/signal/manager/api/RecipientAddress.java
lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java
lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java
lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientTrustedResolver.java
src/main/java/org/asamk/signal/commands/ListContactsCommand.java
src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java
src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java

index 09c4a05a69c4e2e603620a99199b2360c8e31add..9f1cab28f95513c6d5a1f25b440cf289c87db22d 100644 (file)
 {
   "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",
   "methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
index d34b2243596f3dfba14f63ee4f32e29b577e30bc..22b58b56661f316e4374bfe672a9a0ce03d8e59f 100644 (file)
   "queryAllDeclaredConstructors":true,
   "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",
   "allDeclaredFields":true,
   "allDeclaredMethods":true,
   "allDeclaredConstructors":true
 },
+{
+  "name":"org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest",
+  "allDeclaredFields":true,
+  "queryAllDeclaredMethods":true,
+  "queryAllDeclaredConstructors":true
+},
 {
   "name":"org.whispersystems.signalservice.internal.push.DeviceCode",
   "allDeclaredFields":true,
   "allDeclaredMethods":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",
   "allDeclaredFields":true,
     {"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",
   "allDeclaredFields":true,
index d4f8b76f9e47517210fa3c5bb0c987ff3fb6f01f..bcb8f3482c716a9b8fe0dc30d6e1e9460102bc59 100644 (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.InvalidDeviceLinkException;
 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.MessageEnvelope;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@@ -77,6 +78,18 @@ public interface Manager extends Closeable {
      */
     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 deleteAccount() throws IOException;
index 844c3323c192f171d43a34e9cec8615ad1211787..451adb5940deae24f99556b6fc506e89810d24a4 100644 (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.InvalidDeviceLinkException;
 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.MessageEnvelope;
 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.MimeUtils;
 import org.asamk.signal.manager.util.StickerUtils;
+import org.signal.libsignal.usernames.BaseUsernameException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.whispersystems.signalservice.api.SignalSessionLock;
@@ -290,6 +292,20 @@ class ManagerImpl implements Manager {
         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
     public void unregister() throws IOException {
         context.getAccountHelper().unregister();
@@ -737,13 +753,18 @@ class ManagerImpl implements Manager {
 
     @Override
     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
     public void deleteContact(final RecipientIdentifier.Single recipient) {
-        account.getContactStore()
-                .deleteContact(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier()));
+        final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient);
+        if (recipientIdOptional.isPresent()) {
+            account.getContactStore().deleteContact(recipientIdOptional.get());
+        }
     }
 
     @Override
diff --git a/lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java b/lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java
new file mode 100644 (file)
index 0000000..2d3b58a
--- /dev/null
@@ -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);
+    }
+}
index 920eafc55a60ba3fc4d717877104c94dd45f9705..ee3bded9cb148f14cb912bc87427db4881936923 100644 (file)
@@ -6,7 +6,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 import java.util.Optional;
 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();
 
@@ -18,21 +18,25 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
      */
     public RecipientAddress {
         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!");
         }
     }
 
     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) {
-        this(Optional.of(address.getServiceId().uuid()), address.getNumber());
+        this(Optional.of(address.getServiceId().uuid()), address.getNumber(), Optional.empty());
     }
 
     public RecipientAddress(UUID uuid) {
-        this(Optional.of(uuid), Optional.empty());
+        this(Optional.of(uuid), Optional.empty(), Optional.empty());
     }
 
     public ServiceId getServiceId() {
@@ -44,6 +48,8 @@ public record RecipientAddress(Optional<UUID> uuid, Optional<String> number) {
             return uuid.get().toString();
         } else if (number.isPresent()) {
             return number.get();
+        } else if (username.isPresent()) {
+            return username.get();
         } else {
             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();
         } else if (uuid.isPresent()) {
             return uuid.get().toString();
+        } else if (username.isPresent()) {
+            return username.get();
         } else {
             throw new AssertionError("Given the checks in the constructor, this should not be possible.");
         }
     }
 
     public boolean matches(RecipientAddress other) {
-        return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || (
-                number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get())
-        );
+        return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get()))
+                || (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()))
+                || (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get()));
     }
 }
index 70a60d47d0d7f6f55ec0f741b11e68d297960361..d8952ec07810fc1c02c03b78359137ac346a5f8a 100644 (file)
@@ -30,6 +30,10 @@ public sealed interface RecipientIdentifier {
                     return new Uuid(UUID.fromString(identifier));
                 }
 
+                if (identifier.startsWith("u:")) {
+                    return new Username(identifier.substring(2));
+                }
+
                 final var normalizedNumber = PhoneNumberFormatter.formatNumber(identifier, localNumber);
                 if (!normalizedNumber.equals(identifier)) {
                     final Logger logger = LoggerFactory.getLogger(RecipientIdentifier.class);
@@ -46,6 +50,8 @@ public sealed interface RecipientIdentifier {
                 return new Number(address.number().get());
             } else if (address.uuid().isPresent()) {
                 return new Uuid(address.uuid().get());
+            } else if (address.username().isPresent()) {
+                return new Username(address.username().get());
             }
             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 {
 
         @Override
index 2b6c812bc431db5a5c78f8aeb8b5ec58c74e39cf..3bff549d0853ffd61a4df25186024aa1791b64ac 100644 (file)
@@ -15,6 +15,8 @@ import org.asamk.signal.manager.util.Utils;
 import org.signal.libsignal.protocol.IdentityKeyPair;
 import org.signal.libsignal.protocol.InvalidKeyException;
 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.LoggerFactory;
 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.util.DeviceNameUtil;
 import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
+import org.whispersystems.util.Base64UrlSafe;
 
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
+import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
+
 public class AccountHelper {
 
     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()));
     }
 
+    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) {
         final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
         final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
index dc442f0e1a6e6cb8fbdde51433defca2f9a8e06c..9243cebabc795e694775769167301d7d6529268c 100644 (file)
@@ -6,6 +6,8 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
 import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
 import org.asamk.signal.manager.storage.SignalAccount;
 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.LoggerFactory;
 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.SignalServiceAddress;
 import org.whispersystems.signalservice.api.services.CdsiV2Service;
+import org.whispersystems.util.Base64UrlSafe;
 
 import java.io.IOException;
 import java.util.Collection;
@@ -47,7 +50,7 @@ public class RecipientHelper {
         final var number = address.number().get();
         final ServiceId serviceId;
         try {
-            serviceId = getRegisteredUser(number);
+            serviceId = getRegisteredUserByNumber(number);
         } catch (UnregisteredRecipientException e) {
             logger.warn("Failed to get uuid for e164 number: {}", number);
             // Return SignalServiceAddress with unknown UUID
@@ -78,15 +81,33 @@ public class RecipientHelper {
     public RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredRecipientException {
         if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) {
             return account.getRecipientResolver().resolveRecipient(ServiceId.from(uuidRecipient.uuid()));
-        } else {
-            final var number = ((RecipientIdentifier.Number) recipient).number();
-            return account.getRecipientStore().resolveRecipient(number, () -> {
+        } else if (recipient instanceof RecipientIdentifier.Number numberRecipient) {
+            final var number = numberRecipient.number();
+            return account.getRecipientStore().resolveRecipientByNumber(number, () -> {
                 try {
-                    return getRegisteredUser(number);
+                    return getRegisteredUserByNumber(number);
                 } catch (Exception e) {
                     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;
         }
         final var number = address.getNumber().get();
-        final var serviceId = getRegisteredUser(number);
+        final var serviceId = getRegisteredUserByNumber(number);
         return account.getRecipientTrustedResolver()
                 .resolveRecipientTrusted(new SignalServiceAddress(serviceId, number));
     }
@@ -111,7 +132,7 @@ public class RecipientHelper {
         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;
         try {
             aciMap = getRegisteredUsers(Set.of(number));
@@ -153,8 +174,9 @@ public class RecipientHelper {
         return registeredUsers;
     }
 
-    private ACI getRegisteredUserByUsername(String username) throws IOException {
-        return dependencies.getAccountManager().getAciByUsernameHash(username);
+    private ACI getRegisteredUserByUsername(String username) throws IOException, BaseUsernameException {
+        return dependencies.getAccountManager()
+                .getAciByUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
     }
 
     public record RegisteredUser(Optional<ACI> aci, Optional<PNI> pni) {
index 257cea8373bb68cfd6616aac4defc70b13dc7e7c..68eab7f04dfc22e998ddf2d82370a6317c14bee2 100644 (file)
@@ -102,7 +102,11 @@ public class StorageHelper {
 
         final var contactRecord = record.getContact().get();
         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 blocked = contact != null && contact.isBlocked();
@@ -257,6 +261,7 @@ public class StorageHelper {
                     });
         }
         account.getConfigurationStore().setPhoneNumberUnlisted(accountRecord.isPhoneNumberUnlisted());
+        account.setUsername(accountRecord.getUsername());
 
         if (accountRecord.getProfileKey().isPresent()) {
             ProfileKey profileKey;
index c13be697cdf4703802a0fc6f56b3c583d1582cda..c08036be20ab85fe99ecfa3b1f05bcc1adc5e99c 100644 (file)
@@ -22,7 +22,7 @@ import java.sql.SQLException;
 public class AccountDatabase extends Database {
 
     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) {
         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;
+                                        """);
+            }
+        }
     }
 }
index 25847c6cb61592d6986c73d61d9126182f8ebd43..b104eb84931341d2da5804fd28af0ea9e75fcfa3 100644 (file)
@@ -120,6 +120,7 @@ public class SignalAccount implements Closeable {
     private String accountPath;
     private ServiceEnvironment serviceEnvironment;
     private String number;
+    private String username;
     private ACI aci;
     private PNI pni;
     private String sessionId;
@@ -542,6 +543,9 @@ public class SignalAccount implements Closeable {
             serviceEnvironment = ServiceEnvironment.valueOf(rootNode.get("serviceEnvironment").asText());
         }
         registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
+        if (rootNode.hasNonNull("usernameIdentifier")) {
+            username = rootNode.get("usernameIdentifier").asText();
+        }
         if (rootNode.hasNonNull("uuid")) {
             try {
                 aci = ACI.parseOrThrow(rootNode.get("uuid").asText());
@@ -935,6 +939,7 @@ public class SignalAccount implements Closeable {
             rootNode.put("version", CURRENT_STORAGE_VERSION)
                     .put("username", number)
                     .put("serviceEnvironment", serviceEnvironment == null ? null : serviceEnvironment.name())
+                    .put("usernameIdentifier", username)
                     .put("uuid", aci == null ? null : aci.toString())
                     .put("pni", pni == null ? null : pni.toString())
                     .put("sessionId", sessionId)
@@ -1297,6 +1302,15 @@ public class SignalAccount implements Closeable {
         save();
     }
 
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(final String username) {
+        this.username = username;
+        save();
+    }
+
     public ServiceEnvironment getServiceEnvironment() {
         return serviceEnvironment;
     }
@@ -1368,7 +1382,7 @@ public class SignalAccount implements Closeable {
     }
 
     public RecipientAddress getSelfRecipientAddress() {
-        return new RecipientAddress(aci, pni, number);
+        return new RecipientAddress(aci, pni, number, username);
     }
 
     public RecipientId getSelfRecipientId() {
index 6f796b4a61031e3a11a2328242fcddf903f79111..d94a93a3dd9067a30816f03402e14d2205a06740 100644 (file)
@@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
 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.
@@ -38,23 +40,30 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
     }
 
     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) {
-        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) {
-        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) {
-        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) {
-        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) {
@@ -66,7 +75,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
                 this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni)
         ) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId,
                 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) {
@@ -74,7 +84,8 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
                 ? Optional.empty()
                 : this.serviceId,
                 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() {
@@ -118,13 +129,17 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
     }
 
     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) {
         return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.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) {
@@ -142,6 +157,10 @@ public record RecipientAddress(Optional<ServiceId> serviceId, Optional<PNI> pni,
                 number.isPresent() && (
                         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() {
-        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());
     }
 }
index 81470e83d3bf1a1c8e68c7e35846ec9225a775fe..d3b949f8d0850184819f12b38963157130f00758 100644 (file)
@@ -52,6 +52,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
                                     CREATE TABLE recipient (
                                       _id INTEGER PRIMARY KEY AUTOINCREMENT,
                                       number TEXT UNIQUE,
+                                      username TEXT UNIQUE,
                                       uuid BLOB UNIQUE,
                                       pni BLOB UNIQUE,
                                       profile_key BLOB,
@@ -93,7 +94,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
     public RecipientAddress resolveRecipientAddress(RecipientId recipientId) {
         final var sql = (
                 """
-                SELECT r.number, r.uuid, r.pni
+                SELECT r.number, r.uuid, r.pni, r.username
                 FROM %s r
                 WHERE r._id = ?
                 """
@@ -193,7 +194,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
         return new RecipientId(recipientId, this);
     }
 
-    public RecipientId resolveRecipient(
+    public RecipientId resolveRecipientByNumber(
             final String number, Supplier<ServiceId> serviceIdSupplier
     ) throws UnregisteredRecipientException {
         final Optional<RecipientWithAddress> byNumber;
@@ -214,6 +215,28 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
         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) {
         synchronized (recipientsLock) {
             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 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
@@ -309,7 +346,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
         final var sql = (
                 """
                 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.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
@@ -739,7 +776,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
         final var sql = (
                 """
                 UPDATE %s
-                SET number = ?, uuid = ?, pni = ?
+                SET number = ?, uuid = ?, pni = ?, username = ?
                 WHERE _id = ?
                 """
         ).formatted(TABLE_RECIPIENT);
@@ -747,7 +784,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
             statement.setString(1, address.number().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.setLong(4, recipientId.id());
+            statement.setString(4, address.username().orElse(null));
+            statement.setLong(5, recipientId.id());
             statement.executeUpdate();
         }
     }
@@ -800,7 +838,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
             final Connection connection, final String number
     ) throws SQLException {
         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
                         WHERE r.number = ?
                         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(
             final Connection connection, final ServiceId serviceId
     ) throws SQLException {
         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
                         WHERE r.uuid = ? OR r.pni = ?
                         LIMIT 1
@@ -830,16 +883,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
             final Connection connection, final RecipientAddress address
     ) throws SQLException {
         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
                         WHERE r.uuid = ?1 OR r.pni = ?1 OR
                               r.uuid = ?2 OR r.pni = ?2 OR
-                              r.number = ?3
+                              r.number = ?3 OR
+                              r.username = ?4
                         """.formatted(TABLE_RECIPIENT);
         try (final var statement = connection.prepareStatement(sql)) {
             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.setString(3, address.number().orElse(null));
+            statement.setString(4, address.username().orElse(null));
             return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet)
                     .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 pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull);
         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 {
index 38949c64da18942c64f5cc01e8eba787db850238..9fc46d675f9e9a134fb50dc61b160185c2316333 100644 (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.PNI;
+import org.whispersystems.signalservice.api.push.ServiceId;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
 import java.util.Optional;
@@ -15,6 +16,8 @@ public interface RecipientTrustedResolver {
 
     RecipientId resolveRecipientTrusted(Optional<ACI> aci, Optional<PNI> pni, Optional<String> number);
 
+    RecipientId resolveRecipientTrusted(ServiceId serviceId, String username);
+
     class RecipientTrustedResolverWrapper implements RecipientTrustedResolver {
 
         private final Supplier<RecipientTrustedResolver> recipientTrustedResolverSupplier;
@@ -39,5 +42,10 @@ public interface RecipientTrustedResolver {
         ) {
             return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(aci, pni, number);
         }
+
+        @Override
+        public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) {
+            return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(serviceId, username);
+        }
     }
 }
index abcfdca34b47b528e2246aa982fe705ae33e4d04..e3b978b1eec44983b722a1f567314806c660a9f7 100644 (file)
@@ -55,10 +55,12 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
             for (var r : recipients) {
                 final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
                 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(),
                         contact.getName(),
                         profile.getDisplayName(),
+                        r.getAddress().username().orElse(""),
                         contact.getColor(),
                         contact.isBlocked(),
                         contact.getMessageExpirationTime() == 0
@@ -72,6 +74,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
                 final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact();
                 return new JsonContact(address.number().orElse(null),
                         address.uuid().map(UUID::toString).orElse(null),
+                        address.username().orElse(null),
                         contact.getName(),
                         contact.getColor(),
                         contact.isBlocked(),
@@ -96,6 +99,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
     private record JsonContact(
             String number,
             String uuid,
+            String username,
             String name,
             String color,
             boolean isBlocked,
index 7fe7021372221ada699e24c77ee455bc8847cb08..2bb2d110de1f6caee81f05230e6d024980ab4aca 100644 (file)
@@ -1,12 +1,17 @@
 package org.asamk.signal.commands;
 
+import net.sourceforge.argparse4j.impl.Arguments;
 import net.sourceforge.argparse4j.inf.Namespace;
 import net.sourceforge.argparse4j.inf.Subparser;
 
 import org.asamk.signal.commands.exceptions.CommandException;
 import org.asamk.signal.commands.exceptions.IOErrorException;
+import org.asamk.signal.commands.exceptions.UserErrorException;
 import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.InvalidUsernameException;
+import org.asamk.signal.output.JsonWriter;
 import org.asamk.signal.output.OutputWriter;
+import org.asamk.signal.output.PlainTextWriter;
 
 import java.io.IOException;
 
@@ -21,6 +26,11 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
     public void attachToSubparser(final Subparser subparser) {
         subparser.help("Update the account attributes on the signal server.");
         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
@@ -33,5 +43,34 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
         } catch (IOException 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
+    ) {}
 }
index 3cdce2489dd4dd9680f16201d252c1879d5fd125..cefb7a820f68a63030fbd5ff9ffba873c91e18b2 100644 (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.InactiveGroupLinkException;
 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.MessageEnvelope;
 import org.asamk.signal.manager.api.NotPrimaryDeviceException;
@@ -151,6 +152,16 @@ public class DbusManagerImpl implements Manager {
                 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
     public void unregister() throws IOException {
         signal.unregister();