Support numberless accounts over D-Bus

This commit is contained in:
Mariusz Woloszyn 2026-09-17 17:50:06 +00:00
parent 29140bbf64
commit f85763b807
11 changed files with 644 additions and 78 deletions

View File

@ -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.

View File

@ -15,9 +15,13 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/<account-id>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenumber>`.
Note: when daemon was started without explicit `-a ACCOUNT`, use the account's
object path returned by `org.asamk.SignalControl.listAccounts` or
`org.asamk.SignalControl.getAccount`. Numbered accounts use
`/org/asamk/Signal/_<phonenumber>`. Accounts without a phone number use
`/org/asamk/Signal/<ACI>`, with hyphens replaced by underscores.
== Description
@ -49,6 +53,11 @@ All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for be
Phone numbers always have the format +<countrycode><regional number>
For an account without a phone number, use its ACI (Account ID) with the
`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts.
An account without a phone number cannot infer the country code of a recipient;
use a complete international phone number, an ACI, or a Signal username.
== Methods
=== SignalControl interface
@ -73,11 +82,29 @@ For example:
dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256
----
listAccounts() -> accountList<as>::
listAccounts() -> accountList<ao>::
* accountList : Array of all attached accounts in DBus object path form
Exceptions: None
getAccount(identifier<s>) -> account<o>::
* identifier : Phone number or ACI of an attached account
* account : DBus object path of the account
Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown
startLink() -> deviceLinkUri<s>::
* deviceLinkUri : Provisioning URI to scan with the primary Signal device
finishLink(deviceLinkUri<s>, newDeviceName<s>) -> identifier<s>::
* deviceLinkUri : URI returned by startLink
* newDeviceName : Name for the linked device
* identifier : Phone number of the linked account, or its ACI when it has no phone number
Both linking methods support accounts with or without a phone number.
Exceptions: org.asamk.SignalControl.Error.Failure
register(number<s>, voiceVerification<b>) -> <>::
* number : Phone number
* voiceVerification : true = use voice verification; false = use SMS verification
@ -115,9 +142,10 @@ Exceptions: None
The following methods listen to the account's object path, which is constructed as follows:
- for single-account mode: "/org/asamk/Signal/"
- for multi-account mode: "/org/asamk/Signal/" + DBusNumber
* DBusNumber: account's phone number, with underscore (_) replacing plus (+)
- for single-account mode: "/org/asamk/Signal"
- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier
* DBusIdentifier: account's phone number with underscore (_) replacing plus (+),
or its ACI with underscores replacing hyphens when there is no phone number
getContactName(number<s>) -> name<s>::
* number : Phone number
@ -135,7 +163,12 @@ May result in e.g. two entries if a contact and profile name is set.
Exceptions: None
getSelfNumber() -> number<s>::
* number : Your phone number
* number : Your phone number, or an empty string for an account without a phone number
Exceptions: None
getSelfACI() -> aci<s>::
* aci : Your account's ACI (Account ID), available with or without a phone number
Exceptions: None
@ -167,6 +200,14 @@ This is a concatenated list of all defined contacts as well of profiles known (e
Exceptions: None
listRecipientIdentifiers() -> identifiers<as>::
* identifiers : String array of known recipient identifiers, including recipients without a phone number
Uses phone numbers where known and includes ACIs for recipients without a phone
number. Unlike `listNumbers`, this method also includes numberless recipients.
Exceptions: None
removePin() -> <>::
Removes registration PIN protection.

View File

@ -20,6 +20,9 @@ import java.util.Map;
*/
public interface Signal extends DBusInterface {
/**
* Returns the account's phone number, or an empty string for a numberless account.
*/
String getSelfNumber();
String getSelfACI();
@ -186,6 +189,8 @@ public interface Signal extends DBusInterface {
List<String> listNumbers();
List<String> listRecipientIdentifiers();
List<String> getContactNumber(final String name) throws Error.Failure;
@Deprecated

View File

@ -37,7 +37,10 @@ public interface SignalControl extends DBusInterface {
List<DBusPath> listAccounts();
DBusPath getAccount(String number);
/**
* Resolves a phone number or ACI to an exported account's object path.
*/
DBusPath getAccount(String identifier) throws Error.Failure;
interface Error {

View File

@ -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();

View File

@ -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);
}

View File

@ -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<RecipientIdentifier.Single> addresses,
final Optional<String> name
) {
final var numbers = addresses.stream()
.filter(s -> s instanceof RecipientIdentifier.Number)
.map(s -> ((RecipientIdentifier.Number) s).number())
final var identifiers = addresses.stream()
.map(RecipientIdentifier.Single::getIdentifier)
.collect(Collectors.toSet());
return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> {
final var contactBlocked = signal.isContactBlocked(n);
if (blocked.isPresent() && blocked.get() != contactBlocked) {
return null;
}
final var contactName = signal.getContactName(n);
if (onlyContacts && contactName.isEmpty()) {
return null;
}
if (name.isPresent() && !name.get().equals(contactName)) {
return null;
}
return Recipient.newBuilder()
.withAddress(new RecipientAddress(n))
.withContact(new Contact(contactName,
null,
null,
null,
null,
null,
null,
0,
1,
0,
false,
contactBlocked,
0,
false,
false,
false,
null))
.build();
}).filter(Objects::nonNull).toList();
return listRecipientIdentifiers().stream()
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
.map(n -> {
final var contactBlocked = signal.isContactBlocked(n);
if (blocked.isPresent() && blocked.get() != contactBlocked) {
return null;
}
final var contactName = signal.getContactName(n);
if (onlyContacts && contactName.isEmpty()) {
return null;
}
if (name.isPresent() && !name.get().equals(contactName)) {
return null;
}
return Recipient.newBuilder()
.withAddress(getRecipientAddress(n))
.withContact(new Contact(contactName,
null,
null,
null,
null,
null,
null,
0,
1,
0,
false,
contactBlocked,
0,
false,
false,
false,
null))
.build();
})
.filter(Objects::nonNull)
.toList();
}
@Override
@ -870,16 +876,16 @@ public class DbusManagerImpl implements Manager {
(String) group.get("Description").getValue(),
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
((List<String>) group.get("Members").getValue()).stream()
.map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
.map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null))
.collect(Collectors.toSet()),
((List<String>) group.get("PendingMembers").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
((List<String>) group.get("RequestingMembers").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
((List<String>) group.get("Banned").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
(boolean) group.get("IsBlocked").getValue(),
(int) group.get("MessageExpirationTimer").getValue(),
@ -910,7 +916,10 @@ public class DbusManagerImpl implements Manager {
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
final var aci = (String) group.get("Uuid").getValue();
final var number = (String) group.get("Number").getValue();
return new Identity(new RecipientAddress(aci, null, number, null),
return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci,
null,
number.isEmpty() ? null : number,
null),
(byte[]) group.get("Fingerprint").getValue(),
(String) group.get("SafetyNumber").getValue(),
(byte[]) group.get("ScannableSafetyNumber").getValue(),
@ -1073,6 +1082,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<String> listRecipientIdentifiers() {
try {
return signal.listRecipientIdentifiers();
} catch (UnknownMethod e) {
// Older daemons only expose recipients with a known phone number.
return signal.listNumbers();
}
}
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> 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<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
return mentions.stream()
.map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.<String>getValue(a, "recipient")),
.map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.<String>getValue(a, "recipient")),
getValue(a, "start"),
getValue(a, "length")))
.toList();

View File

@ -45,10 +45,7 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
@Override
public List<String> 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);
}

View File

@ -159,11 +159,19 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl {
@Override
public List<DBusPath> listAccounts() {
return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList();
return c.getManagers()
.stream()
.map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier())))
.toList();
}
@Override
public DBusPath getAccount(final String number) {
return new DBusPath(DbusConfig.getObjectPath(number));
public DBusPath getAccount(final String identifier) {
final var manager = c.getManagers()
.stream()
.filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI()))
.findFirst()
.orElseThrow(() -> new Error.Failure("Unknown account: " + identifier));
return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier()));
}
}

View File

@ -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<String> listRecipientIdentifiers() {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
.stream()
.map(r -> r.getAddress().getLegacyIdentifier())
.distinct()
.toList();
}
@Override
public List<String> getContactNumber(final String name) {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))

View File

@ -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<String> 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<MessageEnvelope>();
client.addReceiveHandler((envelope, error) -> received.add(envelope));
for (final var group : List.of(Optional.<GroupId>empty(), Optional.of(groupId))) {
account.emit(messageEnvelope(group));
final var envelope = received.poll(5, TimeUnit.SECONDS);
assertNotNull(envelope);
assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
assertEquals(Optional.of(ACI),
envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
assertEquals(group,
envelope.data()
.orElseThrow()
.groupContext()
.map(MessageEnvelope.Data.GroupContext::groupId));
}
}
assertEquals(0, account.receivers.size());
}
}
}
private static void checkSelfOperations(
final Manager client,
final TestAccount account,
final RecipientIdentifier.Single self
) throws Exception {
final Set<RecipientIdentifier> recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
client.sendTypingMessage(TypingAction.START, recipients);
client.sendRemoteDeleteMessage(123, recipients);
client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
account.calls.stream().map(Call::name).toList());
assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
}
private static String selectAccount(
final String account,
final DBusConnection connection,
final String busname
) throws Exception {
final var selected = new AtomicReference<String>();
final var handler = new CommandHandler(null, null) {
@Override
public void handleLocalCommand(final LocalCommand command, final Manager manager) {
selected.set(manager.getSelfACI());
}
};
DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
return selected.get();
}
private static String newBusname() {
return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
}
private static MessageEnvelope messageEnvelope(final Optional<GroupId> groupId) {
final var data = new MessageEnvelope.Data(123,
groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
Optional.empty(),
Optional.empty(),
Optional.of("Incoming"),
0,
false,
false,
false,
false,
false,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty());
return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
1,
123,
0,
0,
false,
Optional.empty(),
Optional.empty(),
Optional.of(data),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty());
}
private record Call(String name, List<Object> arguments) {}
private static final class TestAccount {
private final Manager manager;
private final List<Call> calls = new CopyOnWriteArrayList<>();
private final List<Runnable> closedListeners = new CopyOnWriteArrayList<>();
private final List<Manager.ReceiveMessageHandler> receivers = new CopyOnWriteArrayList<>();
private List<Recipient> recipients = List.of();
private List<Group> groups = List.of();
private List<Identity> identities = List.of();
private TestAccount(final String number, final String aci) {
manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);
}
return switch (method.getName()) {
case "getSelfNumber" -> number;
case "getSelfACI" -> aci;
case "getLinkedDevices" -> List.of();
case "getGroups" -> groups;
case "getGroup" -> groups.getFirst();
case "getIdentities" -> identities;
case "getRecipients" -> recipients;
case "isContactBlocked" -> false;
case "getContactOrProfileName" -> "Test contact";
case "addAddressChangedListener" -> null;
case "addClosedListener" -> {
closedListeners.add((Runnable) args[0]);
yield null;
}
case "close" -> {
closedListeners.forEach(Runnable::run);
closedListeners.clear();
yield null;
}
case "addReceiveHandler" -> {
receivers.add((Manager.ReceiveMessageHandler) args[0]);
yield null;
}
case "removeReceiveHandler" -> {
receivers.remove(args[0]);
yield null;
}
case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage",
"sendMessageReaction" -> {
calls.add(new Call(method.getName(), List.of(args)));
yield new SendMessageResults(42, Map.of());
}
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
default -> throw new UnsupportedOperationException(method.getName());
};
});
}
private void emit(final MessageEnvelope envelope) {
receivers.forEach(receiver -> receiver.handleMessage(envelope, null));
}
}
}