diff --git a/.gitignore b/.gitignore index 8fa9c8bd..e41d1e40 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ local.properties .settings/ out/ .DS_Store +/bin/ diff --git a/FUNDING.yml b/FUNDING.yml index 308e2dd5..9d269f9a 100644 --- a/FUNDING.yml +++ b/FUNDING.yml @@ -1,2 +1,3 @@ liberapay: asamk +ko_fi: asamk bitcoin: bc1qykae53fry8a8ycgdzgv0rlxfc959hmmllvz698 diff --git a/README.md b/README.md index 9a11ee6e..fe435849 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,15 @@ dependencies. If you have a recent gradle version installed, you can replace `./ ./gradlew build -3. Create shell wrapper in *build/install/signal-cli/bin*: + 3a. Create shell wrapper in *build/install/signal-cli/bin*: ./gradlew installDist -4. Create tar file in *build/distributions*: + 3b. Create tar file in *build/distributions*: ./gradlew distTar -5. Compile and run signal-cli: + 3c. Compile and run signal-cli: ./gradlew run --args="--help" diff --git a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java index 1b00e562..443a7969 100644 --- a/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/RegistrationManager.java @@ -58,7 +58,7 @@ public class RegistrationManager implements Closeable { private final SignalServiceAccountManager accountManager; private final PinHelper pinHelper; - public RegistrationManager( + private RegistrationManager( SignalAccount account, PathConfig pathConfig, ServiceEnvironmentConfig serviceEnvironmentConfig, diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index ece2460f..6b5d1a86 100755 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -33,6 +33,7 @@ Where is according to DBus specification: * : Byte Array * : Array of Byte Arrays * : String Array +* : Array of signed 64 bit integer * : Boolean (0|1) * : Signed 64 bit integer * <> : no return value @@ -43,6 +44,64 @@ Phone numbers always have the format + == Methods +=== Control methods +These methods are available if the daemon is started anonymously (without an explicit `-u USERNAME`). +Requests are sent to `/org/asamk/Signal`; requests related to individual accounts are sent to +`/org/asamk/Signal/_441234567890` where the + dialing code is replaced by an underscore (_). +Only `version()` is activated in single-user mode; the rest are disabled. + +link() -> deviceLinkUri:: +link(newDeviceName) -> deviceLinkUri:: +* newDeviceName : Name to give new device (defaults to "cli" if no name is given) +* deviceLinkUri : URI of newly linked device + +Returns a URI of the form "tsdevice:/?uuid=...". This can be piped to a QR encoder to create a display that +can be captured by a Signal smartphone client. 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` + +Exception: Failure + +listAccounts() -> accountList:: +* accountList : Array of all attached accounts in DBus object path form + +Exceptions: None + +register(number, voiceVerification) -> <>:: +* number : Phone number +* voiceVerification : true = use voice verification; false = use SMS verification + +Exceptions: Failure, InvalidNumber, RequiresCaptcha + +registerWithCaptcha(number, voiceVerification, captcha) -> <>:: +* number : Phone number +* voiceVerification : true = use voice verification; false = use SMS verification +* captcha : Captcha string + +Exceptions: Failure, InvalidNumber, RequiresCaptcha + +verify(number, verificationCode) -> <>:: +* number : Phone number +* verificationCode : Code received from Signal after successful registration request + +Command fails if PIN was set after previous registration; use verifyWithPin instead. + +Exception: Failure, InvalidNumber + +verifyWithPin(number, verificationCode, pin) -> <>:: +* number : Phone number +* verificationCode : Code received from Signal after successful registration request +* pin : PIN you set with setPin command after verifying previous registration + +Exception: Failure, InvalidNumber + +version() -> version:: +* version : Version string of signal-cli + +Exceptions: None + +=== Other methods + updateGroup(groupId, newName, members, avatar) -> groupId:: * groupId : Byte array representing the internal group identifier * newName : New name of group (empty if unchanged) @@ -60,6 +119,13 @@ updateProfile(newName, about , aboutEmoji , avatar, remove) -> <> Exceptions: Failure + +setExpirationTimer(number, expiration) -> <>:: +* number : Phone number of recipient +* expiration : int32 for the number of seconds before messages to this recipient disappear. Set to 0 to disable expiration. + +Exceptions: Failure + setContactBlocked(number, block) -> <>:: * number : Phone number affected by method * block : 0=remove block , 1=blocked @@ -106,6 +172,18 @@ sendGroupMessage(message, attachments, groupId) -> timestamp:: Exceptions: GroupNotFound, Failure, AttachmentInvalid +sendContacts() -> <>:: + +Sends a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device. + +Exceptions: Failure + +sendSyncRequest() -> <>:: + +Sends a synchronization request to the primary device (for group, contacts, ...). Only works if sent from a secondary device. + +Exception: Failure + sendNoteToSelfMessage(message, attachments) -> timestamp:: * message : Text to send (can be UTF8) * attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under) @@ -125,6 +203,19 @@ Depending on the type of the recipient field this sends a message to one or mult Exceptions: AttachmentInvalid, Failure, InvalidNumber, UntrustedIdentity +sendTyping(recipient, stop) -> <>:: +* recipient : Phone number of a single recipient +* targetSentTimestamp : True, if typing state should be stopped + +Exceptions: Failure, GroupNotFound, UntrustedIdentity + + +sendReadReceipt(recipient, targetSentTimestamp) -> <>:: +* recipient : Phone number of a single recipient +* targetSentTimestamp : Array of Longs to identify the corresponding signal messages + +Exceptions: Failure, UntrustedIdentity + sendGroupMessageReaction(emoji, remove, targetAuthor, targetSentTimestamp, groupId) -> timestamp:: * emoji : Unicode grapheme cluster of the emoji * remove : Boolean, whether a previously sent reaction (emoji) should be removed @@ -215,11 +306,59 @@ isGroupBlocked(groupId) -> state:: Exceptions: None, for unknown groups 0 (false) is returned +removePin() -> <>:: + +Removes registration PIN protection. + +Exception: Failure + +setPin(pin) -> <>:: +* pin : PIN you set after registration (resets after 7 days of inactivity) + +Sets a registration lock PIN, to prevent others from registering your number. + +Exception: Failure + version() -> version:: * version : Version string of signal-cli -isRegistred -> result:: -* result : Currently always returns 1=true +isRegistered() -> result:: +isRegistered(number) -> result:: +isRegistered(numbers) -> results:: +* number : Phone number +* numbers : String array of phone numbers +* result : true=number is registered, false=number is not registered +* results : Boolean array of results + +Exception: InvalidNumber for an incorrectly formatted phone number. For unknown numbers, false is returned, but no exception is raised. If no number is given, returns whether you are registered (presumably true). + +addDevice(deviceUri) -> <>:: +* deviceUri : URI in the form of tsdevice:/?uuid=... Normally received from Signal desktop or smartphone app + +Exception: InvalidUri + +listDevices() -> devices:: +* devices : String array of linked devices + +Exception: Failure + +removeDevice(deviceId) -> <>:: +* deviceId : Device ID to remove, obtained from listDevices() command + +Exception: Failure + +updateDeviceName(deviceName) -> <>:: +* deviceName : New name + +Set a new name for this device (main or linked). + +Exception: Failure + +uploadStickerPack(stickerPackPath) -> url:: +* stickerPackPath : Path to the manifest.json file or a zip file in the same directory +* url : URL of sticker pack after successful upload + +Exception: Failure == Signals diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index 29631bd9..d411854b 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -21,6 +21,14 @@ public interface Signal extends DBusInterface { String message, List attachments, List recipients ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UntrustedIdentity; + void sendTyping( + String recipient, boolean stop + ) throws Error.Failure, Error.GroupNotFound, Error.UntrustedIdentity; + + void sendReadReceipt( + String recipient, List targetSentTimestamp + ) throws Error.Failure, Error.UntrustedIdentity; + long sendRemoteDeleteMessage( long targetSentTimestamp, String recipient ) throws Error.Failure, Error.InvalidNumber; @@ -41,6 +49,10 @@ public interface Signal extends DBusInterface { String emoji, boolean remove, String targetAuthor, long targetSentTimestamp, List recipients ) throws Error.InvalidNumber, Error.Failure; + void sendContacts() throws Error.Failure; + + void sendSyncRequest() throws Error.Failure; + long sendNoteToSelfMessage( String message, List attachments ) throws Error.AttachmentInvalid, Error.Failure; @@ -59,6 +71,8 @@ public interface Signal extends DBusInterface { void setContactName(String number, String name) throws Error.InvalidNumber; + void setExpirationTimer(final String number, final int expiration) throws Error.Failure; + void setContactBlocked(String number, boolean blocked) throws Error.InvalidNumber; void setGroupBlocked(byte[] groupId, boolean blocked) throws Error.GroupNotFound, Error.InvalidGroupId; @@ -73,12 +87,28 @@ public interface Signal extends DBusInterface { byte[] groupId, String name, List members, String avatar ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.InvalidGroupId; - boolean isRegistered(); + boolean isRegistered() throws Error.Failure, Error.InvalidNumber; + + boolean isRegistered(String number) throws Error.Failure, Error.InvalidNumber; + + List isRegistered(List numbers) throws Error.Failure, Error.InvalidNumber; + + void addDevice(String uri) throws Error.InvalidUri; + + void removeDevice(int deviceId) throws Error.Failure; + + List listDevices() throws Error.Failure; + + void updateDeviceName(String deviceName) throws Error.Failure; void updateProfile( String name, String about, String aboutEmoji, String avatarPath, boolean removeAvatar ) throws Error.Failure; + void removePin(); + + void setPin(String registrationLockPin); + String version(); List listNumbers(); @@ -97,6 +127,8 @@ public interface Signal extends DBusInterface { byte[] joinGroup(final String groupLink) throws Error.Failure; + String uploadStickerPack(String stickerPackPath) throws Error.Failure; + class MessageReceived extends DBusSignal { private final long timestamp; @@ -223,6 +255,13 @@ public interface Signal extends DBusInterface { } } + class InvalidUri extends DBusExecutionException { + + public InvalidUri(final String message) { + super(message); + } + } + class Failure extends DBusExecutionException { public Failure(final String message) { diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 4cf4d03e..d22e502a 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -5,8 +5,12 @@ import org.asamk.signal.BaseConfig; import org.asamk.signal.manager.AttachmentInvalidException; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.NotMasterDeviceException; +import org.asamk.signal.manager.StickerPackInvalidException; +import org.asamk.signal.manager.UntrustedIdentityException; +import org.asamk.signal.manager.api.Device; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupInviteLinkUrl; import org.asamk.signal.manager.groups.GroupNotFoundException; @@ -17,15 +21,19 @@ import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.util.ErrorUtils; import org.asamk.signal.util.Util; import org.freedesktop.dbus.exceptions.DBusExecutionException; +import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -33,6 +41,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -58,6 +67,51 @@ public class DbusSignalImpl implements Signal { return objectPath; } + @Override + public void addDevice(String uri) { + try { + m.addDeviceLink(new URI(uri)); + } catch (IOException | InvalidKeyException e) { + throw new Error.Failure(e.getClass().getSimpleName() + " Add device link failed. " + e.getMessage()); + } catch (URISyntaxException e) { + throw new Error.InvalidUri(e.getClass().getSimpleName() + + " Device link uri has invalid format: " + + e.getMessage()); + } + } + + @Override + public void removeDevice(int deviceId) { + try { + m.removeLinkedDevices(deviceId); + } catch (IOException e) { + throw new Error.Failure(e.getClass().getSimpleName() + ": Error while removing device: " + e.getMessage()); + } + } + + @Override + public List listDevices() { + List devices; + List results = new ArrayList(); + + try { + devices = m.getLinkedDevices(); + } catch (IOException | Error.Failure e) { + throw new Error.Failure("Failed to get linked devices: " + e.getMessage()); + } + + return devices.stream().map(d -> d.getName() == null ? "" : d.getName()).collect(Collectors.toList()); + } + + @Override + public void updateDeviceName(String deviceName) { + try { + m.updateAccountAttributes(deviceName); + } catch (IOException | Signal.Error.Failure e) { + throw new Error.Failure("UpdateAccount error: " + e.getMessage()); + } + } + @Override public long sendMessage(final String message, final List attachments, final String recipient) { var recipients = new ArrayList(1); @@ -165,6 +219,57 @@ public class DbusSignalImpl implements Signal { } } + @Override + public void sendTyping( + final String recipient, final boolean stop + ) throws Error.Failure, Error.GroupNotFound, Error.UntrustedIdentity { + try { + var recipients = new ArrayList(1); + recipients.add(recipient); + m.sendTypingMessage(stop ? TypingAction.STOP : TypingAction.START, + getSingleRecipientIdentifiers(recipients, m.getUsername()).stream() + .map(RecipientIdentifier.class::cast) + .collect(Collectors.toSet())); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) { + throw new Error.GroupNotFound(e.getMessage()); + } catch (UntrustedIdentityException e) { + throw new Error.UntrustedIdentity(e.getMessage()); + } + } + + @Override + public void sendReadReceipt( + final String recipient, final List timestamps + ) throws Error.Failure, Error.UntrustedIdentity { + try { + m.sendReadReceipt(getSingleRecipientIdentifier(recipient, m.getUsername()), timestamps); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } catch (UntrustedIdentityException e) { + throw new Error.UntrustedIdentity(e.getMessage()); + } + } + + @Override + public void sendContacts() { + try { + m.sendContacts(); + } catch (IOException e) { + throw new Error.Failure("SendContacts error: " + e.getMessage()); + } + } + + @Override + public void sendSyncRequest() { + try { + m.requestAllSyncData(); + } catch (IOException e) { + throw new Error.Failure("Request sync data error: " + e.getMessage()); + } + } + @Override public long sendNoteToSelfMessage( final String message, final List attachments @@ -250,6 +355,15 @@ public class DbusSignalImpl implements Signal { } } + @Override + public void setExpirationTimer(final String number, final int expiration) { + try { + m.setExpirationTimer(getSingleRecipientIdentifier(number, m.getUsername()), expiration); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + } + @Override public void setContactBlocked(final String number, final boolean blocked) { try { @@ -357,6 +471,32 @@ public class DbusSignalImpl implements Signal { return true; } + @Override + public boolean isRegistered(String number) { + var result = isRegistered(List.of(number)); + return result.get(0); + } + + @Override + public List isRegistered(List numbers) { + var results = new ArrayList(); + if (numbers.isEmpty()) { + return results; + } + + Map> registered; + try { + registered = m.areUsersRegistered(new HashSet<>(numbers)); + } catch (IOException e) { + throw new Error.Failure(e.getMessage()); + } + + return numbers.stream().map(number -> { + var uuid = registered.get(number).second(); + return uuid != null; + }).collect(Collectors.toList()); + } + @Override public void updateProfile( final String name, @@ -378,6 +518,28 @@ public class DbusSignalImpl implements Signal { } } + @Override + public void removePin() { + try { + m.setRegistrationLockPin(Optional.absent()); + } catch (UnauthenticatedResponseException e) { + throw new Error.Failure("Remove pin failed with unauthenticated response: " + e.getMessage()); + } catch (IOException e) { + throw new Error.Failure("Remove pin error: " + e.getMessage()); + } + } + + @Override + public void setPin(String registrationLockPin) { + try { + m.setRegistrationLockPin(Optional.of(registrationLockPin)); + } catch (UnauthenticatedResponseException e) { + throw new Error.Failure("Set pin error failed with unauthenticated response: " + e.getMessage()); + } catch (IOException e) { + throw new Error.Failure("Set pin error: " + e.getMessage()); + } + } + // Provide option to query a version string in order to react on potential // future interface changes @Override @@ -483,6 +645,18 @@ public class DbusSignalImpl implements Signal { return m.getUsername(); } + @Override + public String uploadStickerPack(String stickerPackPath) { + File path = new File(stickerPackPath); + try { + return m.uploadStickerPack(path).toString(); + } catch (IOException e) { + throw new Error.Failure("Upload error (maybe image size is too large):" + e.getMessage()); + } catch (StickerPackInvalidException e) { + throw new Error.Failure("Invalid sticker pack: " + e.getMessage()); + } + } + private static void checkSendMessageResult(long timestamp, SendMessageResult result) throws DBusExecutionException { var error = ErrorUtils.getErrorMessageFromSendMessageResult(result);