signal-cli/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java
Mariusz Woloszyn d0724fb5b5
Support linking phone-numberless Signal accounts (#2123)
* Support linking phone-numberless Signal accounts

* Align numberless account support with project style

* Support numberless accounts over D-Bus

* Keep superseded accounts out of numberless account discovery

Record numberless accounts explicitly instead of treating every retained ACI
as a discoverable account. Resolve older index entries against saved account
state, preserving ACI lookup and retrying unavailable state on later reads.

The optional index field remains compatible with version 2 readers. Update
native-image reflection metadata and cover replacement, legacy migration,
locked and missing state, and mismatched ACIs in regression tests.

* Address numberless linking review cleanup

Reuse AccountApiV2 through SignalDependencies and use the shared suspend response helper while preserving linking error handling. Remove the unused account-number accessor and unsupported D-Bus version fallback. Keep numberless linking documentation in the man page and point the README to the wiki.

Validate linking errors through HTTP response fixtures. All 196 tests pass on JDK 25 with an isolated D-Bus session; build and JSON schema generation also pass. Account-index classification is unchanged pending review discussion.
2026-09-20 17:51:57 +02:00

181 lines
7.8 KiB
Java

package org.asamk.signal.dbus;
import org.asamk.Signal;
import org.asamk.SignalControl;
import org.asamk.signal.DbusConfig;
import org.asamk.signal.commands.Command;
import org.asamk.signal.commands.CommandHandler;
import org.asamk.signal.commands.LocalCommand;
import org.asamk.signal.commands.MultiLocalCommand;
import org.asamk.signal.commands.ProvisioningCommand;
import org.asamk.signal.commands.RegistrationCommand;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
import org.freedesktop.dbus.errors.ServiceUnknown;
import org.freedesktop.dbus.errors.UnknownMethod;
import org.freedesktop.dbus.exceptions.DBusException;
import org.freedesktop.dbus.exceptions.DBusExecutionException;
import java.io.IOException;
public class DbusCommandHandler {
public static void initDbusClient(
final Command command,
final String account,
final boolean systemBus,
final String busname,
final CommandHandler commandHandler
) throws CommandException {
try {
final var busType = systemBus ? DBusConnection.DBusBusType.SYSTEM : DBusConnection.DBusBusType.SESSION;
try (var dBusConn = DBusConnectionBuilder.forType(busType).build()) {
handleCommand(command, account, dBusConn, busname, commandHandler);
}
} catch (ServiceUnknown e) {
throw new UserErrorException("signal-cli DBus daemon not running on "
+ (systemBus ? "system" : "session")
+ " bus: "
+ e.getMessage(), e);
} catch (DBusExecutionException | DBusException | IOException e) {
throw new UnexpectedErrorException("Dbus client failed: " + e.getMessage(), e);
}
}
public static void handleCommand(
final Command command,
final String account,
final DBusConnection dBusConn,
final String busname,
final CommandHandler commandHandler
) throws CommandException, DBusException {
try {
if (command instanceof ProvisioningCommand c) {
if (account != null) {
throw new UserErrorException("You cannot specify an account when linking");
}
handleProvisioningCommand(c, dBusConn, busname, commandHandler);
return;
}
if (account == null && command instanceof MultiLocalCommand c) {
handleMultiLocalCommand(c, dBusConn, busname, commandHandler);
return;
}
if (account != null && command instanceof RegistrationCommand c) {
handleRegistrationCommand(c, account, dBusConn, busname, commandHandler);
return;
}
if (!(command instanceof LocalCommand localCommand)) {
throw new UserErrorException("Command only works in multi-account mode");
}
var accountObjectPath = account == null
? tryGetSingleAccountObjectPath(dBusConn, busname)
: getAccountObjectPath(account, dBusConn, busname);
if (accountObjectPath == null) {
accountObjectPath = DbusConfig.getObjectPath(account);
}
handleLocalCommand(localCommand, accountObjectPath, dBusConn, busname, commandHandler);
} catch (UnsupportedOperationException e) {
throw new UserErrorException("Command is not yet implemented via dbus", e);
} catch (DBusExecutionException e) {
throw new UnexpectedErrorException(e.getMessage(), e);
}
}
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
) throws DBusException, CommandException {
var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
try {
final var accounts = control.listAccounts();
if (accounts.isEmpty()) {
throw new UserErrorException("No local users found, you first need to register or link an account");
} else if (accounts.size() > 1) {
throw new UserErrorException(
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
}
return accounts.getFirst().getPath();
} catch (UnknownMethod e) {
// dbus daemon not running in multi-account mode
return null;
}
}
private static void handleMultiLocalCommand(
final MultiLocalCommand c,
final DBusConnection dBusConn,
final String busname,
final CommandHandler commandHandler
) throws CommandException, DBusException {
final var signalControl = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
try (final var multiAccountManager = new DbusMultiAccountManagerImpl(signalControl, dBusConn, busname)) {
commandHandler.handleMultiLocalCommand(c, multiAccountManager);
}
}
private static void handleLocalCommand(
final LocalCommand c,
String accountObjectPath,
final DBusConnection dBusConn,
final String busname,
final CommandHandler commandHandler
) throws CommandException, DBusException {
var signal = dBusConn.getRemoteObject(busname, accountObjectPath, Signal.class);
try (final var manager = new DbusManagerImpl(signal, dBusConn, busname)) {
commandHandler.handleLocalCommand(c, manager);
}
}
private static void handleRegistrationCommand(
final RegistrationCommand c,
String account,
final DBusConnection dBusConn,
final String busname,
final CommandHandler commandHandler
) throws CommandException, DBusException {
final var signalControl = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
try (final var registrationManager = new DbusRegistrationManagerImpl(account, signalControl, dBusConn)) {
commandHandler.handleRegistrationCommand(c, registrationManager);
}
}
private static void handleProvisioningCommand(
final ProvisioningCommand c,
final DBusConnection dBusConn,
final String busname,
final CommandHandler commandHandler
) throws CommandException, DBusException {
final var signalControl = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
final var provisioningManager = new DbusProvisioningManagerImpl(signalControl, dBusConn);
commandHandler.handleProvisioningCommand(c, provisioningManager);
}
}