From f85763b80715a76a31fdb2d6d3eb6ae12228a32f Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Thu, 17 Sep 2026 17:50:06 +0000 Subject: [PATCH] Support numberless accounts over D-Bus --- doc/numberless-accounts.md | 28 +- man/signal-cli-dbus.5.adoc | 55 ++- src/main/java/org/asamk/Signal.java | 5 + src/main/java/org/asamk/SignalControl.java | 5 +- .../asamk/signal/dbus/DbusCommandHandler.java | 26 +- .../org/asamk/signal/dbus/DbusHandler.java | 6 +- .../asamk/signal/dbus/DbusManagerImpl.java | 128 ++++-- .../dbus/DbusMultiAccountManagerImpl.java | 9 +- .../signal/dbus/DbusSignalControlImpl.java | 14 +- .../org/asamk/signal/dbus/DbusSignalImpl.java | 11 +- .../asamk/signal/dbus/NumberlessDbusTest.java | 435 ++++++++++++++++++ 11 files changed, 644 insertions(+), 78 deletions(-) create mode 100644 src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java diff --git a/doc/numberless-accounts.md b/doc/numberless-accounts.md index 4bd773b7..f063786d 100644 --- a/doc/numberless-accounts.md +++ b/doc/numberless-accounts.md @@ -27,9 +27,22 @@ Receive events use the ACI in their `account` field when there is no phone numbe REST wrappers must understand those nullable numbers and ACI selectors before they can be assumed compatible. -Numberless accounts are supported through the CLI and JSON-RPC interfaces. The -D-Bus interface still assumes phone-number-based account identifiers and does not -support numberless accounts. +Numberless accounts are also supported through D-Bus. Start +`signal-cli daemon --dbus`, then use the ACI with the D-Bus client: + +```sh +signal-cli --dbus -a YOUR_ACCOUNT_ACI listGroups +signal-cli --dbus -a YOUR_ACCOUNT_ACI send --note-to-self -m 'Numberless account test' +``` + +In multi-account mode, D-Bus exports numberless accounts under +`/org/asamk/Signal/ACI`, with the ACI's hyphens replaced by underscores. Numbered +accounts retain their phone-number-based paths. Use `SignalControl.listAccounts` +or `SignalControl.getAccount` to discover paths; `getAccount` accepts either a +phone number or an ACI. `Signal.getSelfNumber` returns an empty string when the +account has no phone number, and `Signal.getSelfACI` returns the ACI. +`Signal.listRecipientIdentifiers` includes contacts without a phone number; +`Signal.listNumbers` continues to return only phone numbers. ## Validation @@ -38,6 +51,13 @@ request (including ACI authentication and omission of PNI keys), numbered-accoun linking, missing group credential salts, persisted account reload, account discovery, group membership lookup, recipient-number formatting, and JSON account identifiers. +D-Bus transport tests require a session bus. To run them on an isolated bus, use +`dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks`. +These tests exercise the D-Bus server and client with a local test backend, including +mixed accounts, direct and group messages, self-directed operations, and account +removal. They do not contact Signal. Without a session bus, only the D-Bus unit +tests run and the transport tests are skipped. + Live acceptance requires a real mobile account and is separate from these tests: 1. Link a newly created secondary device using the numberless mobile account. @@ -45,6 +65,8 @@ Live acceptance requires a real mobile account and is separate from these tests: 3. Send and receive a Note to Self message and a DM with a second account. 4. Receive a group message, list the group, and send a reply to that test group. 5. Run the JSON-RPC daemon, restart it, and repeat receiving and sending by ACI. +6. Repeat with `daemon --dbus` and the `--dbus -a YOUR_ACCOUNT_ACI` client, checking + direct messages, group messages, and Note to Self. Only one signal-cli process may open an account's state at a time. Stop the daemon before running one-off commands against the same configuration directory. diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index 34d102b7..7b4a754e 100644 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -15,9 +15,13 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger *signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system] -*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_] org.asamk.Signal. [string:] [array::] +*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/] org.asamk.Signal. [string:] [array::] -Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_`. +Note: when daemon was started without explicit `-a ACCOUNT`, use the account's +object path returned by `org.asamk.SignalControl.listAccounts` or +`org.asamk.SignalControl.getAccount`. Numbered accounts use +`/org/asamk/Signal/_`. Accounts without a phone number use +`/org/asamk/Signal/`, with hyphens replaced by underscores. == Description @@ -49,6 +53,11 @@ All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for be Phone numbers always have the format + +For an account without a phone number, use its ACI (Account ID) with the +`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts. +An account without a phone number cannot infer the country code of a recipient; +use a complete international phone number, an ACI, or a Signal username. + == Methods === SignalControl interface @@ -73,11 +82,29 @@ For example: dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256 ---- -listAccounts() -> accountList:: +listAccounts() -> accountList:: * accountList : Array of all attached accounts in DBus object path form Exceptions: None +getAccount(identifier) -> account:: +* identifier : Phone number or ACI of an attached account +* account : DBus object path of the account + +Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown + +startLink() -> deviceLinkUri:: +* deviceLinkUri : Provisioning URI to scan with the primary Signal device + +finishLink(deviceLinkUri, newDeviceName) -> identifier:: +* deviceLinkUri : URI returned by startLink +* newDeviceName : Name for the linked device +* identifier : Phone number of the linked account, or its ACI when it has no phone number + +Both linking methods support accounts with or without a phone number. + +Exceptions: org.asamk.SignalControl.Error.Failure + register(number, voiceVerification) -> <>:: * number : Phone number * voiceVerification : true = use voice verification; false = use SMS verification @@ -115,9 +142,10 @@ Exceptions: None The following methods listen to the account's object path, which is constructed as follows: -- for single-account mode: "/org/asamk/Signal/" -- for multi-account mode: "/org/asamk/Signal/" + DBusNumber -* DBusNumber: account's phone number, with underscore (_) replacing plus (+) +- for single-account mode: "/org/asamk/Signal" +- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier +* DBusIdentifier: account's phone number with underscore (_) replacing plus (+), + or its ACI with underscores replacing hyphens when there is no phone number getContactName(number) -> name:: * number : Phone number @@ -135,7 +163,12 @@ May result in e.g. two entries if a contact and profile name is set. Exceptions: None getSelfNumber() -> number:: -* number : Your phone number +* number : Your phone number, or an empty string for an account without a phone number + +Exceptions: None + +getSelfACI() -> aci:: +* aci : Your account's ACI (Account ID), available with or without a phone number Exceptions: None @@ -167,6 +200,14 @@ This is a concatenated list of all defined contacts as well of profiles known (e Exceptions: None +listRecipientIdentifiers() -> identifiers:: +* identifiers : String array of known recipient identifiers, including recipients without a phone number + +Uses phone numbers where known and includes ACIs for recipients without a phone +number. Unlike `listNumbers`, this method also includes numberless recipients. + +Exceptions: None + removePin() -> <>:: Removes registration PIN protection. diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index 728ff4c6..2f5b712d 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -20,6 +20,9 @@ import java.util.Map; */ public interface Signal extends DBusInterface { + /** + * Returns the account's phone number, or an empty string for a numberless account. + */ String getSelfNumber(); String getSelfACI(); @@ -186,6 +189,8 @@ public interface Signal extends DBusInterface { List listNumbers(); + List listRecipientIdentifiers(); + List getContactNumber(final String name) throws Error.Failure; @Deprecated diff --git a/src/main/java/org/asamk/SignalControl.java b/src/main/java/org/asamk/SignalControl.java index 3eea790f..540039cd 100644 --- a/src/main/java/org/asamk/SignalControl.java +++ b/src/main/java/org/asamk/SignalControl.java @@ -37,7 +37,10 @@ public interface SignalControl extends DBusInterface { List listAccounts(); - DBusPath getAccount(String number); + /** + * Resolves a phone number or ACI to an exported account's object path. + */ + DBusPath getAccount(String identifier) throws Error.Failure; interface Error { diff --git a/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java b/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java index 546a433f..59c1086a 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java +++ b/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java @@ -55,7 +55,7 @@ public class DbusCommandHandler { try { if (command instanceof ProvisioningCommand c) { if (account != null) { - throw new UserErrorException("You cannot specify a account (phone number) when linking"); + throw new UserErrorException("You cannot specify an account when linking"); } handleProvisioningCommand(c, dBusConn, busname, commandHandler); @@ -74,7 +74,9 @@ public class DbusCommandHandler { throw new UserErrorException("Command only works in multi-account mode"); } - var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null; + var accountObjectPath = account == null + ? tryGetSingleAccountObjectPath(dBusConn, busname) + : getAccountObjectPath(account, dBusConn, busname); if (accountObjectPath == null) { accountObjectPath = DbusConfig.getObjectPath(account); } @@ -86,6 +88,24 @@ public class DbusCommandHandler { } } + private static String getAccountObjectPath( + final String account, + final DBusConnection dBusConn, + final String busname + ) throws DBusException, CommandException { + final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class); + try { + return control.getAccount(account).getPath(); + } catch (UnknownMethod e) { + // A single-account daemon exports Signal directly at the base path. + final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class); + if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) { + throw new UserErrorException("Unknown account: " + account); + } + return DbusConfig.getObjectPath(); + } + } + private static String tryGetSingleAccountObjectPath( final DBusConnection dBusConn, final String busname @@ -97,7 +117,7 @@ public class DbusCommandHandler { throw new UserErrorException("No local users found, you first need to register or link an account"); } else if (accounts.size() > 1) { throw new UserErrorException( - "Multiple users found, you need to specify an account (phone number) with -a"); + "Multiple users found, you need to specify an account (phone number or ACI) with -a"); } return accounts.getFirst().getPath(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusHandler.java b/src/main/java/org/asamk/signal/dbus/DbusHandler.java index 2ed6d11f..e74e141b 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusHandler.java +++ b/src/main/java/org/asamk/signal/dbus/DbusHandler.java @@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable { } }); c.addOnManagerRemovedHandler(m -> { - final var path = DbusConfig.getObjectPath(m.getSelfNumber()); + final var path = DbusConfig.getObjectPath(m.getSelfIdentifier()); try { final var object = connection.getExportedObject(null, path); if (object instanceof DbusSignalImpl dbusSignal) { @@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable { final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart); closeables.add(signal); - return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects); + return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects); } private Thread exportManager(final DBusConnection conn, final Manager m) { - final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber()); + final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier()); return exportDbusObject(conn, objectPath, m); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 74dc12a1..d9c3cf98 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -56,6 +56,7 @@ import org.asamk.signal.manager.api.UsernameLinkUrl; import org.asamk.signal.manager.api.UsernameStatus; import org.freedesktop.dbus.DBusPath; import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.errors.UnknownMethod; import org.freedesktop.dbus.exceptions.DBusException; import org.freedesktop.dbus.exceptions.DBusExecutionException; import org.freedesktop.dbus.interfaces.DBusInterface; @@ -77,6 +78,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -110,7 +112,8 @@ public class DbusManagerImpl implements Manager { @Override public String getSelfNumber() { - return signal.getSelfNumber(); + final var number = signal.getSelfNumber(); + return number.isEmpty() ? null : number; } @Override @@ -438,7 +441,7 @@ public class DbusManagerImpl implements Manager { numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP)); return 0L; }, () -> { - signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP); + signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP); return 0L; }, groupId -> { signal.sendGroupTyping(groupId, action == TypingAction.STOP); @@ -486,7 +489,7 @@ public class DbusManagerImpl implements Manager { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { return handleMessage(recipients, numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers), - () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()), + () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()), groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId)); } @@ -510,7 +513,7 @@ public class DbusManagerImpl implements Manager { remove, targetAuthor.getIdentifier(), targetSentTimestamp, - signal.getSelfNumber()), + getSelfIdentifier()), groupId -> signal.sendGroupMessageReaction(emoji, remove, targetAuthor.getIdentifier(), @@ -809,43 +812,46 @@ public class DbusManagerImpl implements Manager { final Collection addresses, final Optional name ) { - final var numbers = addresses.stream() - .filter(s -> s instanceof RecipientIdentifier.Number) - .map(s -> ((RecipientIdentifier.Number) s).number()) + final var identifiers = addresses.stream() + .map(RecipientIdentifier.Single::getIdentifier) .collect(Collectors.toSet()); - return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> { - final var contactBlocked = signal.isContactBlocked(n); - if (blocked.isPresent() && blocked.get() != contactBlocked) { - return null; - } - final var contactName = signal.getContactName(n); - if (onlyContacts && contactName.isEmpty()) { - return null; - } - if (name.isPresent() && !name.get().equals(contactName)) { - return null; - } - return Recipient.newBuilder() - .withAddress(new RecipientAddress(n)) - .withContact(new Contact(contactName, - null, - null, - null, - null, - null, - null, - 0, - 1, - 0, - false, - contactBlocked, - 0, - false, - false, - false, - null)) - .build(); - }).filter(Objects::nonNull).toList(); + return listRecipientIdentifiers().stream() + .filter(n -> addresses.isEmpty() || identifiers.contains(n)) + .map(n -> { + final var contactBlocked = signal.isContactBlocked(n); + if (blocked.isPresent() && blocked.get() != contactBlocked) { + return null; + } + final var contactName = signal.getContactName(n); + if (onlyContacts && contactName.isEmpty()) { + return null; + } + if (name.isPresent() && !name.get().equals(contactName)) { + return null; + } + return Recipient.newBuilder() + .withAddress(getRecipientAddress(n)) + .withContact(new Contact(contactName, + null, + null, + null, + null, + null, + null, + 0, + 1, + 0, + false, + contactBlocked, + 0, + false, + false, + false, + null)) + .build(); + }) + .filter(Objects::nonNull) + .toList(); } @Override @@ -870,16 +876,16 @@ public class DbusManagerImpl implements Manager { (String) group.get("Description").getValue(), GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()), ((List) group.get("Members").getValue()).stream() - .map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null)) + .map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null)) .collect(Collectors.toSet()), ((List) group.get("PendingMembers").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), ((List) group.get("RequestingMembers").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), ((List) group.get("Banned").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), (boolean) group.get("IsBlocked").getValue(), (int) group.get("MessageExpirationTimer").getValue(), @@ -910,7 +916,10 @@ public class DbusManagerImpl implements Manager { final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity"); final var aci = (String) group.get("Uuid").getValue(); final var number = (String) group.get("Number").getValue(); - return new Identity(new RecipientAddress(aci, null, number, null), + return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci, + null, + number.isEmpty() ? null : number, + null), (byte[]) group.get("Fingerprint").getValue(), (String) group.get("SafetyNumber").getValue(), (byte[]) group.get("ScannableSafetyNumber").getValue(), @@ -1073,6 +1082,23 @@ public class DbusManagerImpl implements Manager { return string == null ? "" : string; } + private static RecipientAddress getRecipientAddress(final String identifier) { + try { + return new RecipientAddress(UUID.fromString(identifier)); + } catch (IllegalArgumentException e) { + return new RecipientAddress(identifier); + } + } + + private List listRecipientIdentifiers() { + try { + return signal.listRecipientIdentifiers(); + } catch (UnknownMethod e) { + // Older daemons only expose recipients with a known phone number. + return signal.listNumbers(); + } + } + private T getRemoteObject(final DBusPath path, final Class type) { try { return connection.getRemoteObject(busname, path.getPath(), type); @@ -1085,7 +1111,7 @@ public class DbusManagerImpl implements Manager { try { this.dbusMsgHandler = messageReceived -> { final var extras = messageReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), 0, messageReceived.getTimestamp(), 0, @@ -1132,7 +1158,7 @@ public class DbusManagerImpl implements Manager { connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler); this.dbusEditMsgHandler = messageReceived -> { final var extras = messageReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), 0, messageReceived.getTimestamp(), 0, @@ -1186,7 +1212,7 @@ public class DbusManagerImpl implements Manager { case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY; default -> MessageEnvelope.Receipt.Type.UNKNOWN; }; - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())), 0, receiptReceived.getTimestamp(), 0, @@ -1207,7 +1233,7 @@ public class DbusManagerImpl implements Manager { this.dbusSyncHandler = syncReceived -> { final var extras = syncReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())), 0, syncReceived.getTimestamp(), 0, @@ -1221,7 +1247,7 @@ public class DbusManagerImpl implements Manager { syncReceived.getTimestamp(), syncReceived.getDestination().isEmpty() ? Optional.empty() - : Optional.of(new RecipientAddress(syncReceived.getDestination())), + : Optional.of(getRecipientAddress(syncReceived.getDestination())), Set.of(), Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(), syncReceived.getGroupId().length > 0 @@ -1326,7 +1352,7 @@ public class DbusManagerImpl implements Manager { final List>> mentions = getValue(extras, "mentions"); return mentions.stream() - .map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.getValue(a, "recipient")), + .map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.getValue(a, "recipient")), getValue(a, "start"), getValue(a, "length"))) .toList(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java index 2b9a0a77..a0083270 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java @@ -45,10 +45,7 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { @Override public List getAccountNumbers() { - return signalControl.listAccounts() - .stream() - .map(a -> getRemoteObject(a, Signal.class).getSelfNumber()) - .toList(); + return getManagers().stream().map(Manager::getSelfNumber).toList(); } @Override @@ -74,8 +71,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { } @Override - public Manager getManager(final String phoneNumber) { - return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class), + public Manager getManager(final String identifier) { + return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class), connection, busname); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java index a20789eb..d139d034 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java @@ -159,11 +159,19 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl { @Override public List listAccounts() { - return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList(); + return c.getManagers() + .stream() + .map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier()))) + .toList(); } @Override - public DBusPath getAccount(final String number) { - return new DBusPath(DbusConfig.getObjectPath(number)); + public DBusPath getAccount(final String identifier) { + final var manager = c.getManagers() + .stream() + .filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI())) + .findFirst() + .orElseThrow(() -> new Error.Failure("Unknown account: " + identifier)); + return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier())); } } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 30d7e4a0..906fe101 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { @Override public String getSelfNumber() { - return m.getSelfNumber(); + return emptyIfNull(m.getSelfNumber()); } @Override @@ -803,6 +803,15 @@ public class DbusSignalImpl implements Signal, AutoCloseable { .toList(); } + @Override + public List listRecipientIdentifiers() { + return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty()) + .stream() + .map(r -> r.getAddress().getLegacyIdentifier()) + .distinct() + .toList(); + } + @Override public List getContactNumber(final String name) { return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name)) diff --git a/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java new file mode 100644 index 00000000..c805a5b7 --- /dev/null +++ b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java @@ -0,0 +1,435 @@ +package org.asamk.signal.dbus; + +import org.asamk.Signal; +import org.asamk.SignalControl; +import org.asamk.signal.DbusConfig; +import org.asamk.signal.commands.CommandHandler; +import org.asamk.signal.commands.ListGroupsCommand; +import org.asamk.signal.commands.LocalCommand; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupMember; +import org.asamk.signal.manager.api.GroupPermission; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.Recipient; +import org.asamk.signal.manager.api.RecipientAddress; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.TrustLevel; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.internal.MultiAccountManagerImpl; +import org.freedesktop.dbus.DBusPath; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder; +import org.freedesktop.dbus.errors.UnknownMethod; +import org.freedesktop.dbus.exceptions.DBusExecutionException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NumberlessDbusTest { + + private static final String NUMBER = "+12025550123"; + private static final String ACI = "11111111-1111-4111-8111-111111111111"; + private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222"; + private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333"; + private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111"; + private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123"; + + @Test + void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() { + final var numberless = new TestAccount(null, ACI); + final var numbered = new TestAccount(NUMBER, OTHER_ACI); + final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null); + final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath()); + + assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)), + Set.copyOf(control.listAccounts())); + assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath()); + assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI)); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = NUMBER) + void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception { + final var account = new TestAccount(number, ACI); + final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true); + final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test"); + + assertEquals(number == null ? "" : number, signal.getSelfNumber()); + assertEquals(number, client.getSelfNumber()); + assertEquals(number == null ? ACI : number, client.getSelfIdentifier()); + checkSelfOperations(client, + account, + number == null + ? new RecipientIdentifier.Uuid(UUID.fromString(ACI)) + : new RecipientIdentifier.Number(number)); + } + + @Test + void readsContactsFromOlderDaemonsWithoutTheNewRecipientListingMethod() { + final var account = new TestAccount(NUMBER, ACI); + account.recipients = List.of(Recipient.newBuilder().withAddress(new RecipientAddress(NUMBER)).build()); + final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true) { + @Override + public List listRecipientIdentifiers() { + throw new UnknownMethod("Not implemented by this daemon"); + } + }; + final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test"); + assertEquals(NUMBER, + client.getRecipients(false, Optional.empty(), Set.of(), Optional.empty()) + .getFirst() + .getAddress() + .number() + .orElseThrow()); + } + + // Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks + @Nested + @Timeout(20) + @EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+") + class SessionBus { + + @Test + void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception { + final var numberless = new TestAccount(null, ACI); + final var numbered = new TestAccount(NUMBER, OTHER_ACI); + final var secondNumberless = new TestAccount(null, THIRD_ACI); + final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, + numbered.manager, + secondNumberless.manager), null); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + accounts, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + final var control = connection.getRemoteObject(busname, + DbusConfig.getObjectPath(), + SignalControl.class); + assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)), + control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet())); + assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath()); + assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath()); + assertEquals(ACI, selectAccount(ACI, connection, busname)); + assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname)); + assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname)); + assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname)); + + final var client = new DbusMultiAccountManagerImpl(control, connection, busname); + assertEquals(3, client.getManagers().size()); + assertEquals(2, client.getAccountNumbers().stream().filter(n -> n == null).count()); + assertNull(client.getManager(ACI).getSelfNumber()); + assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber()); + + final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class); + numberless.manager.close(); + assertEquals(2, control.listAccounts().size()); + assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI)); + assertThrows(DBusExecutionException.class, removed::getSelfACI); + assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI()); + assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber()); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = NUMBER) + void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception { + final var account = new TestAccount(number, ACI); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + account.manager, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + assertEquals(ACI, selectAccount(null, connection, busname)); + assertEquals(ACI, selectAccount(ACI, connection, busname)); + if (number != null) { + assertEquals(ACI, selectAccount(number, connection, busname)); + } + assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname)); + } + } + + @Test + void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception { + final var account = new TestAccount(null, ACI); + final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI)); + final var numberedContact = new RecipientAddress(NUMBER); + account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(), + Recipient.newBuilder().withAddress(numberedContact).build()); + account.identities = List.of(new Identity(contact, + new byte[]{1}, + "test", + new byte[]{2}, + TrustLevel.TRUSTED_UNVERIFIED, + 123)); + final var groupId = GroupId.unknownVersion(new byte[32]); + account.groups = List.of(new Group(groupId, + "Test group", + "", + null, + Set.of(new GroupMember(contact, true, null, null), + new GroupMember(numberedContact, false, null, null)), + Set.of(contact), + Set.of(contact), + Set.of(contact), + false, + 0, + GroupPermission.EVERY_MEMBER, + GroupPermission.EVERY_MEMBER, + GroupPermission.EVERY_MEMBER, + true, + true, + false)); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + account.manager, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class); + assertEquals(List.of(NUMBER), signal.listNumbers()); + assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers())); + try (final var client = new DbusManagerImpl(signal, connection, busname)) { + final var contacts = client.getRecipients(false, + Optional.empty(), + Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))), + Optional.empty()); + assertEquals(1, contacts.size()); + assertEquals(contact, contacts.getFirst().getAddress()); + assertEquals(contact, client.getIdentities().getFirst().recipient()); + final var group = client.getGroups().getFirst(); + assertEquals(Set.of(contact, numberedContact), + group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet())); + assertEquals(Set.of(contact), group.pendingMembers()); + assertEquals(Set.of(contact), group.requestingMembers()); + assertEquals(Set.of(contact), group.bannedMembers()); + } + } + } + + @Test + void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception { + final var account = new TestAccount(null, ACI); + final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + accounts, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + assertEquals(ACI, selectAccount(null, connection, busname)); + final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class); + assertEquals("", signal.getSelfNumber()); + final var groupId = GroupId.unknownVersion(new byte[32]); + assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI)); + assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize())); + assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of())); + assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))), + account.calls.get(0).arguments().get(1)); + assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1)); + assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1)); + assertThrows(Signal.Error.InvalidNumber.class, + () -> signal.sendMessage("Invalid local number", List.of(), "2025550124")); + + account.calls.clear(); + try (final var client = new DbusManagerImpl(signal, connection, busname)) { + checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI))); + final var received = new LinkedBlockingQueue(); + client.addReceiveHandler((envelope, error) -> received.add(envelope)); + for (final var group : List.of(Optional.empty(), Optional.of(groupId))) { + account.emit(messageEnvelope(group)); + final var envelope = received.poll(5, TimeUnit.SECONDS); + assertNotNull(envelope); + assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow()); + assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number()); + assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow()); + assertEquals(Optional.of(ACI), + envelope.data().orElseThrow().mentions().getFirst().recipient().aci()); + assertEquals(group, + envelope.data() + .orElseThrow() + .groupContext() + .map(MessageEnvelope.Data.GroupContext::groupId)); + } + } + assertEquals(0, account.receivers.size()); + } + } + } + + private static void checkSelfOperations( + final Manager client, + final TestAccount account, + final RecipientIdentifier.Single self + ) throws Exception { + final Set recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE); + client.sendTypingMessage(TypingAction.START, recipients); + client.sendRemoteDeleteMessage(123, recipients); + client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false); + assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"), + account.calls.stream().map(Call::name).toList()); + assertEquals(Set.of(self), account.calls.get(0).arguments().get(1)); + assertEquals(Set.of(self), account.calls.get(1).arguments().get(1)); + assertEquals(Set.of(self), account.calls.get(2).arguments().get(4)); + } + + private static String selectAccount( + final String account, + final DBusConnection connection, + final String busname + ) throws Exception { + final var selected = new AtomicReference(); + final var handler = new CommandHandler(null, null) { + @Override + public void handleLocalCommand(final LocalCommand command, final Manager manager) { + selected.set(manager.getSelfACI()); + } + }; + DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler); + return selected.get(); + } + + private static String newBusname() { + return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", ""); + } + + private static MessageEnvelope messageEnvelope(final Optional groupId) { + final var data = new MessageEnvelope.Data(123, + groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)), + Optional.empty(), + Optional.empty(), + Optional.of("Incoming"), + 0, + false, + false, + false, + false, + false, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(), + Optional.empty(), + Optional.empty(), + List.of(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)), + List.of(), + List.of(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))), + 1, + 123, + 0, + 0, + false, + Optional.empty(), + Optional.empty(), + Optional.of(data), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private record Call(String name, List arguments) {} + + private static final class TestAccount { + + private final Manager manager; + private final List calls = new CopyOnWriteArrayList<>(); + private final List closedListeners = new CopyOnWriteArrayList<>(); + private final List receivers = new CopyOnWriteArrayList<>(); + private List recipients = List.of(); + private List groups = List.of(); + private List identities = List.of(); + + private TestAccount(final String number, final String aci) { + manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(), + new Class[]{Manager.class}, + (proxy, method, args) -> { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + return switch (method.getName()) { + case "getSelfNumber" -> number; + case "getSelfACI" -> aci; + case "getLinkedDevices" -> List.of(); + case "getGroups" -> groups; + case "getGroup" -> groups.getFirst(); + case "getIdentities" -> identities; + case "getRecipients" -> recipients; + case "isContactBlocked" -> false; + case "getContactOrProfileName" -> "Test contact"; + case "addAddressChangedListener" -> null; + case "addClosedListener" -> { + closedListeners.add((Runnable) args[0]); + yield null; + } + case "close" -> { + closedListeners.forEach(Runnable::run); + closedListeners.clear(); + yield null; + } + case "addReceiveHandler" -> { + receivers.add((Manager.ReceiveMessageHandler) args[0]); + yield null; + } + case "removeReceiveHandler" -> { + receivers.remove(args[0]); + yield null; + } + case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage", + "sendMessageReaction" -> { + calls.add(new Call(method.getName(), List.of(args))); + yield new SendMessageResults(42, Map.of()); + } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } + + private void emit(final MessageEnvelope envelope) { + receivers.forEach(receiver -> receiver.handleMessage(envelope, null)); + } + } +}