mirror of
https://github.com/AsamK/signal-cli
synced 2025-09-02 04:20:38 +00:00
Merge remote-tracking branch 'AsamK/master'
This commit is contained in:
commit
10ec5058f2
71 changed files with 2273 additions and 688 deletions
14
.idea/codeStyles/Project.xml
generated
14
.idea/codeStyles/Project.xml
generated
|
@ -50,10 +50,24 @@
|
|||
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
|
||||
</XML>
|
||||
<codeStyleSettings language="JAVA">
|
||||
<option name="RIGHT_MARGIN" value="120" />
|
||||
<option name="KEEP_LINE_BREAKS" value="false" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1" />
|
||||
<option name="BLANK_LINES_AFTER_CLASS_HEADER" value="1" />
|
||||
<option name="CALL_PARAMETERS_WRAP" value="5" />
|
||||
<option name="METHOD_PARAMETERS_WRAP" value="5" />
|
||||
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
|
||||
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="5" />
|
||||
<option name="PARENTHESES_EXPRESSION_LPAREN_WRAP" value="true" />
|
||||
<option name="PARENTHESES_EXPRESSION_RPAREN_WRAP" value="true" />
|
||||
<option name="BINARY_OPERATION_WRAP" value="5" />
|
||||
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
|
||||
<option name="TERNARY_OPERATION_WRAP" value="5" />
|
||||
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
|
||||
<option name="ENUM_CONSTANTS_WRAP" value="2" />
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<arrangement>
|
||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -1,6 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Accept group invitation with `updateGroup -g GROUP_ID`
|
||||
- Decline group invitation with `quitGroup -g GROUP_ID`
|
||||
|
||||
### Fixed
|
||||
- Include group ids for v2 groups in json output
|
||||
|
||||
## [0.7.0] - 2020-12-15
|
||||
### Added
|
||||
Support for groups of new type/v2
|
||||
- Sending and receiving
|
||||
- Updating name, avatar and adding members with `updateGroup`
|
||||
- Quit group and decline invitation with `quitGroup`
|
||||
- In the `listGroups` output v2 groups can be recognized by the longer groupId
|
||||
|
||||
**Attention**: For the new group support to work the native libzkgroup library is required.
|
||||
See https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal for more information.
|
||||
|
||||
### Fixed
|
||||
- Rare NullPointerException when receiving messages
|
||||
|
||||
## [0.6.12] - 2020-11-22
|
||||
### Added
|
||||
|
|
|
@ -7,7 +7,7 @@ targetCompatibility = JavaVersion.VERSION_11
|
|||
|
||||
mainClassName = 'org.asamk.signal.Main'
|
||||
|
||||
version = '0.6.12'
|
||||
version = '0.7.0'
|
||||
|
||||
compileJava.options.encoding = 'UTF-8'
|
||||
|
||||
|
@ -20,7 +20,7 @@ dependencies {
|
|||
implementation 'com.github.turasa:signal-service-java:2.15.3_unofficial_15'
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.67'
|
||||
implementation 'net.sourceforge.argparse4j:argparse4j:0.8.1'
|
||||
implementation 'com.github.hypfvieh:dbus-java:3.2.3'
|
||||
implementation 'com.github.hypfvieh:dbus-java:3.2.4'
|
||||
implementation 'org.slf4j:slf4j-nop:1.7.30'
|
||||
}
|
||||
|
||||
|
|
|
@ -181,6 +181,7 @@ Output received messages in json format, one object per line.
|
|||
=== updateGroup
|
||||
|
||||
Create or update a group.
|
||||
If the user is a pending member, this command will accept the group invitation.
|
||||
|
||||
*-g* GROUP, *--group* GROUP::
|
||||
Specify the recipient group ID in base64 encoding.
|
||||
|
@ -198,6 +199,7 @@ Specify one or more members to add to the group.
|
|||
=== quitGroup
|
||||
|
||||
Send a quit group message to all group members and remove self from member list.
|
||||
If the user is a pending member, this command will decline the group invitation.
|
||||
|
||||
*-g* GROUP, *--group* GROUP::
|
||||
Specify the recipient group ID in base64 encoding.
|
||||
|
@ -235,7 +237,7 @@ Specify the safety number of the key, only use this option if you have verified
|
|||
|
||||
Update the name and avatar image visible by message recipients for the current users.
|
||||
The profile is stored encrypted on the Signal servers.
|
||||
The decryption key is sent with every outgoing messages (excluding group messages).
|
||||
The decryption key is sent with every outgoing messages to contacts.
|
||||
|
||||
*--name*::
|
||||
New name visible by message recipients.
|
||||
|
|
|
@ -13,13 +13,19 @@ import java.util.List;
|
|||
*/
|
||||
public interface Signal extends DBusInterface {
|
||||
|
||||
long sendMessage(String message, List<String> attachments, String recipient) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber;
|
||||
long sendMessage(
|
||||
String message, List<String> attachments, String recipient
|
||||
) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber;
|
||||
|
||||
long sendMessage(String message, List<String> attachments, List<String> recipients) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
long sendMessage(
|
||||
String message, List<String> attachments, List<String> recipients
|
||||
) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
|
||||
void sendEndSessionMessage(List<String> recipients) throws Error.Failure, Error.InvalidNumber, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
|
||||
long sendGroupMessage(String message, List<String> attachments, byte[] groupId) throws Error.GroupNotFound, Error.Failure, Error.AttachmentInvalid, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
long sendGroupMessage(
|
||||
String message, List<String> attachments, byte[] groupId
|
||||
) throws Error.GroupNotFound, Error.Failure, Error.AttachmentInvalid, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
|
||||
String getContactName(String number) throws Error.InvalidNumber;
|
||||
|
||||
|
@ -35,7 +41,9 @@ public interface Signal extends DBusInterface {
|
|||
|
||||
List<String> getGroupMembers(byte[] groupId);
|
||||
|
||||
byte[] updateGroup(byte[] groupId, String name, List<String> members, String avatar) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
byte[] updateGroup(
|
||||
byte[] groupId, String name, List<String> members, String avatar
|
||||
) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.UnregisteredUser, Error.UntrustedIdentity;
|
||||
|
||||
boolean isRegistered();
|
||||
|
||||
|
@ -47,7 +55,14 @@ public interface Signal extends DBusInterface {
|
|||
private final String message;
|
||||
private final List<String> attachments;
|
||||
|
||||
public MessageReceived(String objectpath, long timestamp, String sender, byte[] groupId, String message, List<String> attachments) throws DBusException {
|
||||
public MessageReceived(
|
||||
String objectpath,
|
||||
long timestamp,
|
||||
String sender,
|
||||
byte[] groupId,
|
||||
String message,
|
||||
List<String> attachments
|
||||
) throws DBusException {
|
||||
super(objectpath, timestamp, sender, groupId, message, attachments);
|
||||
this.timestamp = timestamp;
|
||||
this.sender = sender;
|
||||
|
@ -106,7 +121,15 @@ public interface Signal extends DBusInterface {
|
|||
private final String message;
|
||||
private final List<String> attachments;
|
||||
|
||||
public SyncMessageReceived(String objectpath, long timestamp, String source, String destination, byte[] groupId, String message, List<String> attachments) throws DBusException {
|
||||
public SyncMessageReceived(
|
||||
String objectpath,
|
||||
long timestamp,
|
||||
String source,
|
||||
String destination,
|
||||
byte[] groupId,
|
||||
String message,
|
||||
List<String> attachments
|
||||
) throws DBusException {
|
||||
super(objectpath, timestamp, source, destination, groupId, message, attachments);
|
||||
this.timestamp = timestamp;
|
||||
this.source = source;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.asamk.signal;
|
||||
|
||||
import org.asamk.Signal;
|
||||
import org.asamk.signal.manager.GroupUtils;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection;
|
||||
import org.freedesktop.dbus.exceptions.DBusException;
|
||||
|
@ -29,30 +30,33 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
|
|||
this.objectPath = objectPath;
|
||||
}
|
||||
|
||||
static void sendReceivedMessageToDbus(SignalServiceEnvelope envelope, SignalServiceContent content, DBusConnection conn, final String objectPath, Manager m) {
|
||||
static void sendReceivedMessageToDbus(
|
||||
SignalServiceEnvelope envelope,
|
||||
SignalServiceContent content,
|
||||
DBusConnection conn,
|
||||
final String objectPath,
|
||||
Manager m
|
||||
) {
|
||||
if (envelope.isReceipt()) {
|
||||
try {
|
||||
conn.sendMessage(new Signal.ReceiptReceived(
|
||||
objectPath,
|
||||
envelope.getTimestamp(),
|
||||
conn.sendMessage(new Signal.ReceiptReceived(objectPath, envelope.getTimestamp(),
|
||||
// A receipt envelope always has a source address
|
||||
envelope.getSourceAddress().getLegacyIdentifier()
|
||||
));
|
||||
envelope.getSourceAddress().getLegacyIdentifier()));
|
||||
} catch (DBusException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else if (content != null) {
|
||||
final SignalServiceAddress sender = !envelope.isUnidentifiedSender() && envelope.hasSource() ? envelope.getSourceAddress() : content.getSender();
|
||||
final SignalServiceAddress sender = !envelope.isUnidentifiedSender() && envelope.hasSource()
|
||||
? envelope.getSourceAddress()
|
||||
: content.getSender();
|
||||
if (content.getReceiptMessage().isPresent()) {
|
||||
final SignalServiceReceiptMessage receiptMessage = content.getReceiptMessage().get();
|
||||
if (receiptMessage.isDeliveryReceipt()) {
|
||||
for (long timestamp : receiptMessage.getTimestamps()) {
|
||||
try {
|
||||
conn.sendMessage(new Signal.ReceiptReceived(
|
||||
objectPath,
|
||||
conn.sendMessage(new Signal.ReceiptReceived(objectPath,
|
||||
timestamp,
|
||||
sender.getLegacyIdentifier()
|
||||
));
|
||||
sender.getLegacyIdentifier()));
|
||||
} catch (DBusException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
@ -62,13 +66,13 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
|
|||
SignalServiceDataMessage message = content.getDataMessage().get();
|
||||
|
||||
byte[] groupId = getGroupId(m, message);
|
||||
if (!message.isEndSession() &&
|
||||
(groupId == null
|
||||
if (!message.isEndSession() && (
|
||||
groupId == null
|
||||
|| message.getGroupContext().get().getGroupV1Type() == null
|
||||
|| message.getGroupContext().get().getGroupV1Type() == SignalServiceGroup.Type.DELIVER)) {
|
||||
|| message.getGroupContext().get().getGroupV1Type() == SignalServiceGroup.Type.DELIVER
|
||||
)) {
|
||||
try {
|
||||
conn.sendMessage(new Signal.MessageReceived(
|
||||
objectPath,
|
||||
conn.sendMessage(new Signal.MessageReceived(objectPath,
|
||||
message.getTimestamp(),
|
||||
sender.getLegacyIdentifier(),
|
||||
groupId != null ? groupId : new byte[0],
|
||||
|
@ -83,16 +87,19 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
|
|||
if (sync_message.getSent().isPresent()) {
|
||||
SentTranscriptMessage transcript = sync_message.getSent().get();
|
||||
|
||||
if (transcript.getDestination().isPresent() || transcript.getMessage().getGroupContext().isPresent()) {
|
||||
if (transcript.getDestination().isPresent() || transcript.getMessage()
|
||||
.getGroupContext()
|
||||
.isPresent()) {
|
||||
SignalServiceDataMessage message = transcript.getMessage();
|
||||
byte[] groupId = getGroupId(m, message);
|
||||
|
||||
try {
|
||||
conn.sendMessage(new Signal.SyncMessageReceived(
|
||||
objectPath,
|
||||
conn.sendMessage(new Signal.SyncMessageReceived(objectPath,
|
||||
transcript.getTimestamp(),
|
||||
sender.getLegacyIdentifier(),
|
||||
transcript.getDestination().isPresent() ? transcript.getDestination().get().getLegacyIdentifier() : "",
|
||||
transcript.getDestination().isPresent() ? transcript.getDestination()
|
||||
.get()
|
||||
.getLegacyIdentifier() : "",
|
||||
groupId != null ? groupId : new byte[0],
|
||||
message.getBody().isPresent() ? message.getBody().get() : "",
|
||||
JsonDbusReceiveMessageHandler.getAttachments(message, m)));
|
||||
|
@ -111,7 +118,7 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
|
|||
if (message.getGroupContext().get().getGroupV1().isPresent()) {
|
||||
groupId = message.getGroupContext().get().getGroupV1().get().getGroupId();
|
||||
} else if (message.getGroupContext().get().getGroupV2().isPresent()) {
|
||||
groupId = m.getGroupId(message.getGroupContext().get().getGroupV2().get().getMasterKey());
|
||||
groupId = GroupUtils.getGroupId(message.getGroupContext().get().getGroupV2().get().getMasterKey());
|
||||
} else {
|
||||
groupId = null;
|
||||
}
|
||||
|
|
|
@ -84,8 +84,8 @@ public class Main {
|
|||
busType = DBusConnection.DBusBusType.SESSION;
|
||||
}
|
||||
try (DBusConnection dBusConn = DBusConnection.getConnection(busType)) {
|
||||
Signal ts = dBusConn.getRemoteObject(
|
||||
DbusConfig.SIGNAL_BUSNAME, DbusConfig.SIGNAL_OBJECTPATH,
|
||||
Signal ts = dBusConn.getRemoteObject(DbusConfig.SIGNAL_BUSNAME,
|
||||
DbusConfig.SIGNAL_OBJECTPATH,
|
||||
Signal.class);
|
||||
|
||||
return handleCommands(ns, ts, dBusConn);
|
||||
|
@ -103,7 +103,8 @@ public class Main {
|
|||
dataPath = getDefaultDataPath();
|
||||
}
|
||||
|
||||
final SignalServiceConfiguration serviceConfiguration = ServiceConfig.createDefaultServiceConfiguration(BaseConfig.USER_AGENT);
|
||||
final SignalServiceConfiguration serviceConfiguration = ServiceConfig.createDefaultServiceConfiguration(
|
||||
BaseConfig.USER_AGENT);
|
||||
|
||||
if (username == null) {
|
||||
ProvisioningManager pm = new ProvisioningManager(dataPath, serviceConfiguration, BaseConfig.USER_AGENT);
|
||||
|
@ -225,23 +226,16 @@ public class Main {
|
|||
.description("Commandline interface for Signal.")
|
||||
.version(BaseConfig.PROJECT_NAME + " " + BaseConfig.PROJECT_VERSION);
|
||||
|
||||
parser.addArgument("-v", "--version")
|
||||
.help("Show package version.")
|
||||
.action(Arguments.version());
|
||||
parser.addArgument("-v", "--version").help("Show package version.").action(Arguments.version());
|
||||
parser.addArgument("--config")
|
||||
.help("Set the path, where to store the config (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli).");
|
||||
parser.addArgument("-n", "--busname")
|
||||
.help("Name of the DBus.");
|
||||
|
||||
MutuallyExclusiveGroup mut = parser.addMutuallyExclusiveGroup();
|
||||
mut.addArgument("-u", "--username")
|
||||
.help("Specify your phone number, that will be used for verification.");
|
||||
mut.addArgument("--dbus")
|
||||
.help("Make request via user dbus.")
|
||||
.action(Arguments.storeTrue());
|
||||
mut.addArgument("--dbus-system")
|
||||
.help("Make request via system dbus.")
|
||||
.action(Arguments.storeTrue());
|
||||
mut.addArgument("-u", "--username").help("Specify your phone number, that will be used for verification.");
|
||||
mut.addArgument("--dbus").help("Make request via user dbus.").action(Arguments.storeTrue());
|
||||
mut.addArgument("--dbus-system").help("Make request via system dbus.").action(Arguments.storeTrue());
|
||||
|
||||
Subparsers subparsers = parser.addSubparsers()
|
||||
.title("subcommands")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.asamk.signal;
|
||||
|
||||
import org.asamk.signal.manager.GroupUtils;
|
||||
import org.asamk.signal.manager.Manager;
|
||||
import org.asamk.signal.storage.contacts.ContactInfo;
|
||||
import org.asamk.signal.storage.groups.GroupInfo;
|
||||
|
@ -52,7 +53,9 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
if (!envelope.isUnidentifiedSender() && envelope.hasSource()) {
|
||||
SignalServiceAddress source = envelope.getSourceAddress();
|
||||
ContactInfo sourceContact = m.getContact(source.getLegacyIdentifier());
|
||||
System.out.println(String.format("Envelope from: %s (device: %d)", (sourceContact == null ? "" : "“" + sourceContact.name + "” ") + source.getLegacyIdentifier(), envelope.getSourceDevice()));
|
||||
System.out.println(String.format("Envelope from: %s (device: %d)",
|
||||
(sourceContact == null ? "" : "“" + sourceContact.name + "” ") + source.getLegacyIdentifier(),
|
||||
envelope.getSourceDevice()));
|
||||
if (source.getRelay().isPresent()) {
|
||||
System.out.println("Relayed by: " + source.getRelay().get());
|
||||
}
|
||||
|
@ -70,18 +73,35 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
if (exception != null) {
|
||||
if (exception instanceof org.whispersystems.libsignal.UntrustedIdentityException) {
|
||||
org.whispersystems.libsignal.UntrustedIdentityException e = (org.whispersystems.libsignal.UntrustedIdentityException) exception;
|
||||
System.out.println("The user’s key is untrusted, either the user has reinstalled Signal or a third party sent this message.");
|
||||
System.out.println("Use 'signal-cli -u " + m.getUsername() + " listIdentities -n " + e.getName() + "', verify the key and run 'signal-cli -u " + m.getUsername() + " trust -v \"FINGER_PRINT\" " + e.getName() + "' to mark it as trusted");
|
||||
System.out.println("If you don't care about security, use 'signal-cli -u " + m.getUsername() + " trust -a " + e.getName() + "' to trust it without verification");
|
||||
System.out.println(
|
||||
"The user’s key is untrusted, either the user has reinstalled Signal or a third party sent this message.");
|
||||
System.out.println("Use 'signal-cli -u "
|
||||
+ m.getUsername()
|
||||
+ " listIdentities -n "
|
||||
+ e.getName()
|
||||
+ "', verify the key and run 'signal-cli -u "
|
||||
+ m.getUsername()
|
||||
+ " trust -v \"FINGER_PRINT\" "
|
||||
+ e.getName()
|
||||
+ "' to mark it as trusted");
|
||||
System.out.println("If you don't care about security, use 'signal-cli -u "
|
||||
+ m.getUsername()
|
||||
+ " trust -a "
|
||||
+ e.getName()
|
||||
+ "' to trust it without verification");
|
||||
} else {
|
||||
System.out.println("Exception: " + exception.getMessage() + " (" + exception.getClass().getSimpleName() + ")");
|
||||
System.out.println("Exception: " + exception.getMessage() + " (" + exception.getClass()
|
||||
.getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
if (content == null) {
|
||||
System.out.println("Failed to decrypt message.");
|
||||
} else {
|
||||
ContactInfo sourceContact = m.getContact(content.getSender().getLegacyIdentifier());
|
||||
System.out.println(String.format("Sender: %s (device: %d)", (sourceContact == null ? "" : "“" + sourceContact.name + "” ") + content.getSender().getLegacyIdentifier(), content.getSenderDevice()));
|
||||
System.out.println(String.format("Sender: %s (device: %d)",
|
||||
(sourceContact == null ? "" : "“" + sourceContact.name + "” ") + content.getSender()
|
||||
.getLegacyIdentifier(),
|
||||
content.getSenderDevice()));
|
||||
if (content.getDataMessage().isPresent()) {
|
||||
SignalServiceDataMessage message = content.getDataMessage().get();
|
||||
handleSignalServiceDataMessage(message);
|
||||
|
@ -107,7 +127,11 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
System.out.println("Received sync read messages list");
|
||||
for (ReadMessage rm : syncMessage.getRead().get()) {
|
||||
ContactInfo fromContact = m.getContact(rm.getSender().getLegacyIdentifier());
|
||||
System.out.println("From: " + (fromContact == null ? "" : "“" + fromContact.name + "” ") + rm.getSender().getLegacyIdentifier() + " Message timestamp: " + DateUtils.formatTimestamp(rm.getTimestamp()));
|
||||
System.out.println("From: "
|
||||
+ (fromContact == null ? "" : "“" + fromContact.name + "” ")
|
||||
+ rm.getSender().getLegacyIdentifier()
|
||||
+ " Message timestamp: "
|
||||
+ DateUtils.formatTimestamp(rm.getTimestamp()));
|
||||
}
|
||||
}
|
||||
if (syncMessage.getRequest().isPresent()) {
|
||||
|
@ -140,15 +164,19 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
StringBuilder toBuilder = new StringBuilder();
|
||||
for (SignalServiceAddress dest : sentTranscriptMessage.getRecipients()) {
|
||||
ContactInfo destContact = m.getContact(dest.getLegacyIdentifier());
|
||||
toBuilder.append(destContact == null ? "" : "“" + destContact.name + "” ").append(dest.getLegacyIdentifier()).append(" ");
|
||||
toBuilder.append(destContact == null ? "" : "“" + destContact.name + "” ")
|
||||
.append(dest.getLegacyIdentifier())
|
||||
.append(" ");
|
||||
}
|
||||
to = toBuilder.toString();
|
||||
} else {
|
||||
to = "Unknown";
|
||||
}
|
||||
System.out.println("To: " + to + " , Message timestamp: " + DateUtils.formatTimestamp(sentTranscriptMessage.getTimestamp()));
|
||||
System.out.println("To: " + to + " , Message timestamp: " + DateUtils.formatTimestamp(
|
||||
sentTranscriptMessage.getTimestamp()));
|
||||
if (sentTranscriptMessage.getExpirationStartTimestamp() > 0) {
|
||||
System.out.println("Expiration started at: " + DateUtils.formatTimestamp(sentTranscriptMessage.getExpirationStartTimestamp()));
|
||||
System.out.println("Expiration started at: " + DateUtils.formatTimestamp(
|
||||
sentTranscriptMessage.getExpirationStartTimestamp()));
|
||||
}
|
||||
SignalServiceDataMessage message = sentTranscriptMessage.getMessage();
|
||||
handleSignalServiceDataMessage(message);
|
||||
|
@ -164,24 +192,38 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
if (syncMessage.getVerified().isPresent()) {
|
||||
System.out.println("Received sync message with verified identities:");
|
||||
final VerifiedMessage verifiedMessage = syncMessage.getVerified().get();
|
||||
System.out.println(" - " + verifiedMessage.getDestination() + ": " + verifiedMessage.getVerified());
|
||||
String safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(verifiedMessage.getDestination(), verifiedMessage.getIdentityKey()));
|
||||
System.out.println(" - "
|
||||
+ verifiedMessage.getDestination()
|
||||
+ ": "
|
||||
+ verifiedMessage.getVerified());
|
||||
String safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(verifiedMessage.getDestination(),
|
||||
verifiedMessage.getIdentityKey()));
|
||||
System.out.println(" " + safetyNumber);
|
||||
}
|
||||
if (syncMessage.getConfiguration().isPresent()) {
|
||||
System.out.println("Received sync message with configuration:");
|
||||
final ConfigurationMessage configurationMessage = syncMessage.getConfiguration().get();
|
||||
if (configurationMessage.getReadReceipts().isPresent()) {
|
||||
System.out.println(" - Read receipts: " + (configurationMessage.getReadReceipts().get() ? "enabled" : "disabled"));
|
||||
System.out.println(" - Read receipts: " + (
|
||||
configurationMessage.getReadReceipts().get() ? "enabled" : "disabled"
|
||||
));
|
||||
}
|
||||
if (configurationMessage.getLinkPreviews().isPresent()) {
|
||||
System.out.println(" - Link previews: " + (configurationMessage.getLinkPreviews().get() ? "enabled" : "disabled"));
|
||||
System.out.println(" - Link previews: " + (
|
||||
configurationMessage.getLinkPreviews().get() ? "enabled" : "disabled"
|
||||
));
|
||||
}
|
||||
if (configurationMessage.getTypingIndicators().isPresent()) {
|
||||
System.out.println(" - Typing indicators: " + (configurationMessage.getTypingIndicators().get() ? "enabled" : "disabled"));
|
||||
System.out.println(" - Typing indicators: " + (
|
||||
configurationMessage.getTypingIndicators().get() ? "enabled" : "disabled"
|
||||
));
|
||||
}
|
||||
if (configurationMessage.getUnidentifiedDeliveryIndicators().isPresent()) {
|
||||
System.out.println(" - Unidentified Delivery Indicators: " + (configurationMessage.getUnidentifiedDeliveryIndicators().get() ? "enabled" : "disabled"));
|
||||
System.out.println(" - Unidentified Delivery Indicators: " + (
|
||||
configurationMessage.getUnidentifiedDeliveryIndicators().get()
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
));
|
||||
}
|
||||
}
|
||||
if (syncMessage.getFetchType().isPresent()) {
|
||||
|
@ -195,7 +237,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
System.out.println(" - Timestamp:" + viewOnceOpenMessage.getTimestamp());
|
||||
}
|
||||
if (syncMessage.getStickerPackOperations().isPresent()) {
|
||||
final List<StickerPackOperationMessage> stickerPackOperationMessages = syncMessage.getStickerPackOperations().get();
|
||||
final List<StickerPackOperationMessage> stickerPackOperationMessages = syncMessage.getStickerPackOperations()
|
||||
.get();
|
||||
System.out.println("Received sync message with sticker pack operations:");
|
||||
for (StickerPackOperationMessage m : stickerPackOperationMessages) {
|
||||
System.out.println(" - " + m.getType().toString());
|
||||
|
@ -208,21 +251,27 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
}
|
||||
}
|
||||
if (syncMessage.getMessageRequestResponse().isPresent()) {
|
||||
final MessageRequestResponseMessage requestResponseMessage = syncMessage.getMessageRequestResponse().get();
|
||||
final MessageRequestResponseMessage requestResponseMessage = syncMessage.getMessageRequestResponse()
|
||||
.get();
|
||||
System.out.println("Received message request response:");
|
||||
System.out.println(" Type: " + requestResponseMessage.getType());
|
||||
if (requestResponseMessage.getGroupId().isPresent()) {
|
||||
System.out.println(" Group id: " + Base64.encodeBytes(requestResponseMessage.getGroupId().get()));
|
||||
System.out.println(" Group id: " + Base64.encodeBytes(requestResponseMessage.getGroupId()
|
||||
.get()));
|
||||
}
|
||||
if (requestResponseMessage.getPerson().isPresent()) {
|
||||
System.out.println(" Person: " + requestResponseMessage.getPerson().get().getLegacyIdentifier());
|
||||
System.out.println(" Person: " + requestResponseMessage.getPerson()
|
||||
.get()
|
||||
.getLegacyIdentifier());
|
||||
}
|
||||
}
|
||||
if (syncMessage.getKeys().isPresent()) {
|
||||
final KeysMessage keysMessage = syncMessage.getKeys().get();
|
||||
System.out.println("Received sync message with keys:");
|
||||
if (keysMessage.getStorageService().isPresent()) {
|
||||
System.out.println(" With storage key length: " + keysMessage.getStorageService().get().serialize().length);
|
||||
System.out.println(" With storage key length: " + keysMessage.getStorageService()
|
||||
.get()
|
||||
.serialize().length);
|
||||
} else {
|
||||
System.out.println(" With empty storage key");
|
||||
}
|
||||
|
@ -246,7 +295,10 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
if (callMessage.getIceUpdateMessages().isPresent()) {
|
||||
List<IceUpdateMessage> iceUpdateMessages = callMessage.getIceUpdateMessages().get();
|
||||
for (IceUpdateMessage iceUpdateMessage : iceUpdateMessages) {
|
||||
System.out.println("Ice update message: " + iceUpdateMessage.getId() + ", sdp: " + iceUpdateMessage.getSdp());
|
||||
System.out.println("Ice update message: "
|
||||
+ iceUpdateMessage.getId()
|
||||
+ ", sdp: "
|
||||
+ iceUpdateMessage.getSdp());
|
||||
}
|
||||
}
|
||||
if (callMessage.getOfferMessage().isPresent()) {
|
||||
|
@ -329,7 +381,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
}
|
||||
} else if (groupContext.getGroupV2().isPresent()) {
|
||||
final SignalServiceGroupV2 groupInfo = groupContext.getGroupV2().get();
|
||||
byte[] groupId = m.getGroupId(groupInfo.getMasterKey());
|
||||
byte[] groupId = GroupUtils.getGroupId(groupInfo.getMasterKey());
|
||||
System.out.println(" Id: " + Base64.encodeBytes(groupId));
|
||||
GroupInfo group = m.getGroup(groupId);
|
||||
if (group != null) {
|
||||
|
@ -386,7 +438,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
final SignalServiceDataMessage.Reaction reaction = message.getReaction().get();
|
||||
System.out.println("Reaction:");
|
||||
System.out.println(" - Emoji: " + reaction.getEmoji());
|
||||
System.out.println(" - Target author: " + m.resolveSignalServiceAddress(reaction.getTargetAuthor()).getLegacyIdentifier());
|
||||
System.out.println(" - Target author: " + m.resolveSignalServiceAddress(reaction.getTargetAuthor())
|
||||
.getLegacyIdentifier());
|
||||
System.out.println(" - Target timestamp: " + reaction.getTargetSentTimestamp());
|
||||
System.out.println(" - Is remove: " + reaction.isRemove());
|
||||
}
|
||||
|
@ -417,7 +470,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
final List<SignalServiceDataMessage.Mention> mentions = message.getMentions().get();
|
||||
System.out.println("Mentions: ");
|
||||
for (SignalServiceDataMessage.Mention mention : mentions) {
|
||||
System.out.println("- " + mention.getUuid() + ": " + mention.getStart() + " (length: " + mention.getLength() + ")");
|
||||
System.out.println("- "
|
||||
+ mention.getUuid()
|
||||
+ ": "
|
||||
+ mention.getStart()
|
||||
+ " (length: "
|
||||
+ mention.getLength()
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -430,12 +489,22 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
|
|||
}
|
||||
|
||||
private void printAttachment(SignalServiceAttachment attachment) {
|
||||
System.out.println("- " + attachment.getContentType() + " (" + (attachment.isPointer() ? "Pointer" : "") + (attachment.isStream() ? "Stream" : "") + ")");
|
||||
System.out.println("- " + attachment.getContentType() + " (" + (attachment.isPointer() ? "Pointer" : "") + (
|
||||
attachment.isStream() ? "Stream" : ""
|
||||
) + ")");
|
||||
if (attachment.isPointer()) {
|
||||
final SignalServiceAttachmentPointer pointer = attachment.asPointer();
|
||||
System.out.println(" Id: " + pointer.getRemoteId() + " Key length: " + pointer.getKey().length);
|
||||
System.out.println(" Filename: " + (pointer.getFileName().isPresent() ? pointer.getFileName().get() : "-"));
|
||||
System.out.println(" Size: " + (pointer.getSize().isPresent() ? pointer.getSize().get() + " bytes" : "<unavailable>") + (pointer.getPreview().isPresent() ? " (Preview is available: " + pointer.getPreview().get().length + " bytes)" : ""));
|
||||
System.out.println(" Filename: " + (
|
||||
pointer.getFileName().isPresent() ? pointer.getFileName().get() : "-"
|
||||
));
|
||||
System.out.println(" Size: " + (
|
||||
pointer.getSize().isPresent() ? pointer.getSize().get() + " bytes" : "<unavailable>"
|
||||
) + (
|
||||
pointer.getPreview().isPresent() ? " (Preview is available: "
|
||||
+ pointer.getPreview().get().length
|
||||
+ " bytes)" : ""
|
||||
));
|
||||
System.out.println(" Voice note: " + (pointer.getVoiceNote() ? "yes" : "no"));
|
||||
System.out.println(" Dimensions: " + pointer.getWidth() + "x" + pointer.getHeight());
|
||||
File file = m.getAttachmentFile(pointer.getRemoteId());
|
||||
|
|
|
@ -13,12 +13,8 @@ public class BlockCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("contact")
|
||||
.help("Contact number")
|
||||
.nargs("*");
|
||||
subparser.addArgument("-g", "--group")
|
||||
.help("Group ID")
|
||||
.nargs("*");
|
||||
subparser.addArgument("contact").help("Contact number").nargs("*");
|
||||
subparser.addArgument("-g", "--group").help("Group ID").nargs("*");
|
||||
subparser.help("Block the given contacts or groups (no messages will be received)");
|
||||
}
|
||||
|
||||
|
|
|
@ -66,7 +66,13 @@ public class DaemonCommand implements LocalCommand {
|
|||
}
|
||||
boolean ignoreAttachments = ns.getBoolean("ignore_attachments");
|
||||
try {
|
||||
m.receiveMessages(1, TimeUnit.HOURS, false, ignoreAttachments, ns.getBoolean("json") ? new JsonDbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH) : new DbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH));
|
||||
m.receiveMessages(1,
|
||||
TimeUnit.HOURS,
|
||||
false,
|
||||
ignoreAttachments,
|
||||
ns.getBoolean("json")
|
||||
? new JsonDbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH)
|
||||
: new DbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH));
|
||||
return 0;
|
||||
} catch (IOException e) {
|
||||
System.err.println("Error while receiving messages: " + e.getMessage());
|
||||
|
|
|
@ -16,8 +16,7 @@ public class LinkCommand implements ProvisioningCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-n", "--name")
|
||||
.help("Specify a name to describe this new device.");
|
||||
subparser.addArgument("-n", "--name").help("Specify a name to describe this new device.");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -43,7 +42,11 @@ public class LinkCommand implements ProvisioningCommand {
|
|||
e.printStackTrace();
|
||||
return 2;
|
||||
} catch (UserAlreadyExists e) {
|
||||
System.err.println("The user " + e.getUsername() + " already exists\nDelete \"" + e.getFileName() + "\" before trying again.");
|
||||
System.err.println("The user "
|
||||
+ e.getUsername()
|
||||
+ " already exists\nDelete \""
|
||||
+ e.getFileName()
|
||||
+ "\" before trying again.");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
|
|
@ -25,7 +25,10 @@ public class ListDevicesCommand implements LocalCommand {
|
|||
try {
|
||||
List<DeviceInfo> devices = m.getLinkedDevices();
|
||||
for (DeviceInfo d : devices) {
|
||||
System.out.println("Device " + d.getId() + (d.getId() == m.getDeviceId() ? " (this device)" : "") + ":");
|
||||
System.out.println("Device "
|
||||
+ d.getId()
|
||||
+ (d.getId() == m.getDeviceId() ? " (this device)" : "")
|
||||
+ ":");
|
||||
System.out.println(" Name: " + d.getName());
|
||||
System.out.println(" Created: " + DateUtils.formatTimestamp(d.getCreated()));
|
||||
System.out.println(" Last seen: " + DateUtils.formatTimestamp(d.getLastSeen()));
|
||||
|
|
|
@ -22,18 +22,40 @@ public class ListGroupsCommand implements LocalCommand {
|
|||
.map(m::resolveSignalServiceAddress)
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
System.out.println(String.format("Id: %s Name: %s Active: %s Blocked: %b Members: %s",
|
||||
Base64.encodeBytes(group.groupId), group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked(), members));
|
||||
|
||||
Set<String> pendingMembers = group.getPendingMembers()
|
||||
.stream()
|
||||
.map(m::resolveSignalServiceAddress)
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<String> requestingMembers = group.getRequestingMembers()
|
||||
.stream()
|
||||
.map(m::resolveSignalServiceAddress)
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
System.out.println(String.format(
|
||||
"Id: %s Name: %s Active: %s Blocked: %b Members: %s Pending members: %s Requesting members: %s",
|
||||
Base64.encodeBytes(group.groupId),
|
||||
group.getTitle(),
|
||||
group.isMember(m.getSelfAddress()),
|
||||
group.isBlocked(),
|
||||
members,
|
||||
pendingMembers,
|
||||
requestingMembers));
|
||||
} else {
|
||||
System.out.println(String.format("Id: %s Name: %s Active: %s Blocked: %b",
|
||||
Base64.encodeBytes(group.groupId), group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked()));
|
||||
Base64.encodeBytes(group.groupId),
|
||||
group.getTitle(),
|
||||
group.isMember(m.getSelfAddress()),
|
||||
group.isBlocked()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-d", "--detailed").action(Arguments.storeTrue())
|
||||
.help("List members of each group");
|
||||
subparser.addArgument("-d", "--detailed").action(Arguments.storeTrue()).help("List members of each group");
|
||||
subparser.help("List group name and ids");
|
||||
}
|
||||
|
||||
|
|
|
@ -15,14 +15,17 @@ public class ListIdentitiesCommand implements LocalCommand {
|
|||
|
||||
private static void printIdentityFingerprint(Manager m, JsonIdentityKeyStore.Identity theirId) {
|
||||
String digits = Util.formatSafetyNumber(m.computeSafetyNumber(theirId.getAddress(), theirId.getIdentityKey()));
|
||||
System.out.println(String.format("%s: %s Added: %s Fingerprint: %s Safety Number: %s", theirId.getAddress().getNumber().orNull(),
|
||||
theirId.getTrustLevel(), theirId.getDateAdded(), Hex.toString(theirId.getFingerprint()), digits));
|
||||
System.out.println(String.format("%s: %s Added: %s Fingerprint: %s Safety Number: %s",
|
||||
theirId.getAddress().getNumber().orNull(),
|
||||
theirId.getTrustLevel(),
|
||||
theirId.getDateAdded(),
|
||||
Hex.toString(theirId.getFingerprint()),
|
||||
digits));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-n", "--number")
|
||||
.help("Only show identity keys for the given phone number.");
|
||||
subparser.addArgument("-n", "--number").help("Only show identity keys for the given phone number.");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -25,9 +25,7 @@ public class QuitGroupCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-g", "--group")
|
||||
.required(true)
|
||||
.help("Specify the recipient group ID.");
|
||||
subparser.addArgument("-g", "--group").required(true).help("Specify the recipient group ID.");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -38,7 +36,8 @@ public class QuitGroupCommand implements LocalCommand {
|
|||
}
|
||||
|
||||
try {
|
||||
final Pair<Long, List<SendMessageResult>> results = m.sendQuitGroupMessage(Util.decodeGroupId(ns.getString("group")));
|
||||
final byte[] groupId = Util.decodeGroupId(ns.getString("group"));
|
||||
final Pair<Long, List<SendMessageResult>> results = m.sendQuitGroupMessage(groupId);
|
||||
return handleTimestampAndSendMessageResults(results.first(), results.second());
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
|
|
|
@ -63,7 +63,9 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand {
|
|||
}
|
||||
} else {
|
||||
System.out.print(String.format("Envelope from: %s\nTimestamp: %s\nBody: %s\n",
|
||||
messageReceived.getSender(), DateUtils.formatTimestamp(messageReceived.getTimestamp()), messageReceived.getMessage()));
|
||||
messageReceived.getSender(),
|
||||
DateUtils.formatTimestamp(messageReceived.getTimestamp()),
|
||||
messageReceived.getMessage()));
|
||||
if (messageReceived.getGroupId().length > 0) {
|
||||
System.out.println("Group info:");
|
||||
System.out.println(" Id: " + Base64.encodeBytes(messageReceived.getGroupId()));
|
||||
|
@ -78,23 +80,23 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand {
|
|||
}
|
||||
});
|
||||
|
||||
dbusconnection.addSigHandler(Signal.ReceiptReceived.class,
|
||||
receiptReceived -> {
|
||||
if (jsonProcessor != null) {
|
||||
JsonMessageEnvelope envelope = new JsonMessageEnvelope(receiptReceived);
|
||||
ObjectNode result = jsonProcessor.createObjectNode();
|
||||
result.putPOJO("envelope", envelope);
|
||||
try {
|
||||
jsonProcessor.writeValue(System.out, result);
|
||||
System.out.println();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else {
|
||||
System.out.print(String.format("Receipt from: %s\nTimestamp: %s\n",
|
||||
receiptReceived.getSender(), DateUtils.formatTimestamp(receiptReceived.getTimestamp())));
|
||||
}
|
||||
});
|
||||
dbusconnection.addSigHandler(Signal.ReceiptReceived.class, receiptReceived -> {
|
||||
if (jsonProcessor != null) {
|
||||
JsonMessageEnvelope envelope = new JsonMessageEnvelope(receiptReceived);
|
||||
ObjectNode result = jsonProcessor.createObjectNode();
|
||||
result.putPOJO("envelope", envelope);
|
||||
try {
|
||||
jsonProcessor.writeValue(System.out, result);
|
||||
System.out.println();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else {
|
||||
System.out.print(String.format("Receipt from: %s\nTimestamp: %s\n",
|
||||
receiptReceived.getSender(),
|
||||
DateUtils.formatTimestamp(receiptReceived.getTimestamp())));
|
||||
}
|
||||
});
|
||||
|
||||
dbusconnection.addSigHandler(Signal.SyncMessageReceived.class, syncReceived -> {
|
||||
if (jsonProcessor != null) {
|
||||
|
@ -109,7 +111,10 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand {
|
|||
}
|
||||
} else {
|
||||
System.out.print(String.format("Sync Envelope from: %s to: %s\nTimestamp: %s\nBody: %s\n",
|
||||
syncReceived.getSource(), syncReceived.getDestination(), DateUtils.formatTimestamp(syncReceived.getTimestamp()), syncReceived.getMessage()));
|
||||
syncReceived.getSource(),
|
||||
syncReceived.getDestination(),
|
||||
DateUtils.formatTimestamp(syncReceived.getTimestamp()),
|
||||
syncReceived.getMessage()));
|
||||
if (syncReceived.getGroupId().length > 0) {
|
||||
System.out.println("Group info:");
|
||||
System.out.println(" Id: " + Base64.encodeBytes(syncReceived.getGroupId()));
|
||||
|
@ -156,8 +161,14 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand {
|
|||
}
|
||||
boolean ignoreAttachments = ns.getBoolean("ignore_attachments");
|
||||
try {
|
||||
final Manager.ReceiveMessageHandler handler = ns.getBoolean("json") ? new JsonReceiveMessageHandler(m) : new ReceiveMessageHandler(m);
|
||||
m.receiveMessages((long) (timeout * 1000), TimeUnit.MILLISECONDS, returnOnTimeout, ignoreAttachments, handler);
|
||||
final Manager.ReceiveMessageHandler handler = ns.getBoolean("json")
|
||||
? new JsonReceiveMessageHandler(m)
|
||||
: new ReceiveMessageHandler(m);
|
||||
m.receiveMessages((long) (timeout * 1000),
|
||||
TimeUnit.MILLISECONDS,
|
||||
returnOnTimeout,
|
||||
ignoreAttachments,
|
||||
handler);
|
||||
return 0;
|
||||
} catch (IOException e) {
|
||||
System.err.println("Error while receiving messages: " + e.getMessage());
|
||||
|
|
|
@ -22,16 +22,10 @@ public class SendCommand implements DbusCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-g", "--group")
|
||||
.help("Specify the recipient group ID.");
|
||||
subparser.addArgument("recipient")
|
||||
.help("Specify the recipients' phone number.")
|
||||
.nargs("*");
|
||||
subparser.addArgument("-m", "--message")
|
||||
.help("Specify the message, if missing standard input is used.");
|
||||
subparser.addArgument("-a", "--attachment")
|
||||
.nargs("*")
|
||||
.help("Add file as attachment");
|
||||
subparser.addArgument("-g", "--group").help("Specify the recipient group ID.");
|
||||
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
|
||||
subparser.addArgument("-m", "--message").help("Specify the message, if missing standard input is used.");
|
||||
subparser.addArgument("-a", "--attachment").nargs("*").help("Add file as attachment");
|
||||
subparser.addArgument("-e", "--endsession")
|
||||
.help("Clear session state and send end session message.")
|
||||
.action(Arguments.storeTrue());
|
||||
|
@ -44,7 +38,9 @@ public class SendCommand implements DbusCommand {
|
|||
return 1;
|
||||
}
|
||||
|
||||
if ((ns.getList("recipient") == null || ns.getList("recipient").size() == 0) && (ns.getBoolean("endsession") || ns.getString("group") == null)) {
|
||||
if ((ns.getList("recipient") == null || ns.getList("recipient").size() == 0) && (
|
||||
ns.getBoolean("endsession") || ns.getString("group") == null
|
||||
)) {
|
||||
System.err.println("No recipients given");
|
||||
System.err.println("Aborting sending.");
|
||||
return 1;
|
||||
|
|
|
@ -29,11 +29,8 @@ public class SendReactionCommand implements LocalCommand {
|
|||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.help("Send reaction to a previously received or sent message.");
|
||||
subparser.addArgument("-g", "--group")
|
||||
.help("Specify the recipient group ID.");
|
||||
subparser.addArgument("recipient")
|
||||
.help("Specify the recipients' phone number.")
|
||||
.nargs("*");
|
||||
subparser.addArgument("-g", "--group").help("Specify the recipient group ID.");
|
||||
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
|
||||
subparser.addArgument("-e", "--emoji")
|
||||
.required(true)
|
||||
.help("Specify the emoji, should be a single unicode grapheme cluster.");
|
||||
|
@ -44,9 +41,7 @@ public class SendReactionCommand implements LocalCommand {
|
|||
.required(true)
|
||||
.type(long.class)
|
||||
.help("Specify the timestamp of the message to which to react.");
|
||||
subparser.addArgument("-r", "--remove")
|
||||
.help("Remove a reaction.")
|
||||
.action(Arguments.storeTrue());
|
||||
subparser.addArgument("-r", "--remove").help("Remove a reaction.").action(Arguments.storeTrue());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -73,7 +68,11 @@ public class SendReactionCommand implements LocalCommand {
|
|||
byte[] groupId = Util.decodeGroupId(ns.getString("group"));
|
||||
results = m.sendGroupMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, groupId);
|
||||
} else {
|
||||
results = m.sendMessageReaction(emoji, isRemove, targetAuthor, targetTimestamp, ns.getList("recipient"));
|
||||
results = m.sendMessageReaction(emoji,
|
||||
isRemove,
|
||||
targetAuthor,
|
||||
targetTimestamp,
|
||||
ns.getList("recipient"));
|
||||
}
|
||||
handleTimestampAndSendMessageResults(results.first(), results.second());
|
||||
return 0;
|
||||
|
|
|
@ -16,9 +16,7 @@ public class TrustCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("number")
|
||||
.help("Specify the phone number, for which to set the trust.")
|
||||
.required(true);
|
||||
subparser.addArgument("number").help("Specify the phone number, for which to set the trust.").required(true);
|
||||
MutuallyExclusiveGroup mutTrust = subparser.addMutuallyExclusiveGroup();
|
||||
mutTrust.addArgument("-a", "--trust-all-known-keys")
|
||||
.help("Trust all known keys of this user, only use this for testing.")
|
||||
|
@ -49,7 +47,8 @@ public class TrustCommand implements LocalCommand {
|
|||
try {
|
||||
fingerprintBytes = Hex.toByteArray(safetyNumber.toLowerCase(Locale.ROOT));
|
||||
} catch (Exception e) {
|
||||
System.err.println("Failed to parse the fingerprint, make sure the fingerprint is a correctly encoded hex string without additional characters.");
|
||||
System.err.println(
|
||||
"Failed to parse the fingerprint, make sure the fingerprint is a correctly encoded hex string without additional characters.");
|
||||
return 1;
|
||||
}
|
||||
boolean res;
|
||||
|
@ -60,7 +59,8 @@ public class TrustCommand implements LocalCommand {
|
|||
return 1;
|
||||
}
|
||||
if (!res) {
|
||||
System.err.println("Failed to set the trust for the fingerprint of this number, make sure the number and the fingerprint are correct.");
|
||||
System.err.println(
|
||||
"Failed to set the trust for the fingerprint of this number, make sure the number and the fingerprint are correct.");
|
||||
return 1;
|
||||
}
|
||||
} else if (safetyNumber.length() == 60) {
|
||||
|
@ -72,15 +72,18 @@ public class TrustCommand implements LocalCommand {
|
|||
return 1;
|
||||
}
|
||||
if (!res) {
|
||||
System.err.println("Failed to set the trust for the safety number of this phone number, make sure the phone number and the safety number are correct.");
|
||||
System.err.println(
|
||||
"Failed to set the trust for the safety number of this phone number, make sure the phone number and the safety number are correct.");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
System.err.println("Safety number has invalid format, either specify the old hex fingerprint or the new safety number");
|
||||
System.err.println(
|
||||
"Safety number has invalid format, either specify the old hex fingerprint or the new safety number");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
System.err.println("You need to specify the fingerprint/safety number you have verified with -v SAFETY_NUMBER");
|
||||
System.err.println(
|
||||
"You need to specify the fingerprint/safety number you have verified with -v SAFETY_NUMBER");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,12 +13,8 @@ public class UnblockCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("contact")
|
||||
.help("Contact number")
|
||||
.nargs("*");
|
||||
subparser.addArgument("-g", "--group")
|
||||
.help("Group ID")
|
||||
.nargs("*");
|
||||
subparser.addArgument("contact").help("Contact number").nargs("*");
|
||||
subparser.addArgument("-g", "--group").help("Group ID").nargs("*");
|
||||
subparser.help("Unblock the given contacts or groups (messages will be received again)");
|
||||
}
|
||||
|
||||
|
|
|
@ -12,11 +12,8 @@ public class UpdateContactCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("number")
|
||||
.help("Contact number");
|
||||
subparser.addArgument("-n", "--name")
|
||||
.required(true)
|
||||
.help("New contact name");
|
||||
subparser.addArgument("number").help("Contact number");
|
||||
subparser.addArgument("-n", "--name").required(true).help("New contact name");
|
||||
subparser.addArgument("-e", "--expiration")
|
||||
.required(false)
|
||||
.type(int.class)
|
||||
|
|
|
@ -19,15 +19,10 @@ public class UpdateGroupCommand implements DbusCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("-g", "--group")
|
||||
.help("Specify the recipient group ID.");
|
||||
subparser.addArgument("-n", "--name")
|
||||
.help("Specify the new group name.");
|
||||
subparser.addArgument("-a", "--avatar")
|
||||
.help("Specify a new group avatar image file");
|
||||
subparser.addArgument("-m", "--member")
|
||||
.nargs("*")
|
||||
.help("Specify one or more members to add to the group");
|
||||
subparser.addArgument("-g", "--group").help("Specify the recipient group ID.");
|
||||
subparser.addArgument("-n", "--name").help("Specify the new group name.");
|
||||
subparser.addArgument("-a", "--avatar").help("Specify a new group avatar image file");
|
||||
subparser.addArgument("-m", "--member").nargs("*").help("Specify one or more members to add to the group");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -14,16 +14,11 @@ public class UpdateProfileCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
final MutuallyExclusiveGroup avatarOptions = subparser.addMutuallyExclusiveGroup()
|
||||
.required(true);
|
||||
avatarOptions.addArgument("--avatar")
|
||||
.help("Path to new profile avatar");
|
||||
avatarOptions.addArgument("--remove-avatar")
|
||||
.action(Arguments.storeTrue());
|
||||
final MutuallyExclusiveGroup avatarOptions = subparser.addMutuallyExclusiveGroup().required(true);
|
||||
avatarOptions.addArgument("--avatar").help("Path to new profile avatar");
|
||||
avatarOptions.addArgument("--remove-avatar").action(Arguments.storeTrue());
|
||||
|
||||
subparser.addArgument("--name")
|
||||
.required(true)
|
||||
.help("New profile name");
|
||||
subparser.addArgument("--name").required(true).help("New profile name");
|
||||
|
||||
subparser.help("Set a name and avatar image for the user profile");
|
||||
}
|
||||
|
|
|
@ -12,10 +12,8 @@ public class VerifyCommand implements LocalCommand {
|
|||
|
||||
@Override
|
||||
public void attachToSubparser(final Subparser subparser) {
|
||||
subparser.addArgument("verificationCode")
|
||||
.help("The verification code you received via sms or voice call.");
|
||||
subparser.addArgument("-p", "--pin")
|
||||
.help("The registration lock PIN, that was set by the user (Optional)");
|
||||
subparser.addArgument("verificationCode").help("The verification code you received via sms or voice call.");
|
||||
subparser.addArgument("-p", "--pin").help("The registration lock PIN, that was set by the user (Optional)");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -30,7 +28,8 @@ public class VerifyCommand implements LocalCommand {
|
|||
m.verifyAccount(verificationCode, pin);
|
||||
return 0;
|
||||
} catch (LockedException e) {
|
||||
System.err.println("Verification failed! This number is locked with a pin. Hours remaining until reset: " + (e.getTimeRemaining() / 1000 / 60 / 60));
|
||||
System.err.println("Verification failed! This number is locked with a pin. Hours remaining until reset: "
|
||||
+ (e.getTimeRemaining() / 1000 / 60 / 60));
|
||||
System.err.println("Use '--pin PIN_CODE' to specify the registration lock PIN");
|
||||
return 3;
|
||||
} catch (IOException e) {
|
||||
|
|
|
@ -44,7 +44,9 @@ public class DbusSignalImpl implements Signal {
|
|||
return sendMessage(message, attachments, recipients);
|
||||
}
|
||||
|
||||
private static void checkSendMessageResults(long timestamp, List<SendMessageResult> results) throws DBusExecutionException {
|
||||
private static void checkSendMessageResults(
|
||||
long timestamp, List<SendMessageResult> results
|
||||
) throws DBusExecutionException {
|
||||
List<String> errors = ErrorUtils.getErrorMessagesFromSendMessageResults(results);
|
||||
if (errors.size() == 0) {
|
||||
return;
|
||||
|
@ -164,13 +166,29 @@ public class DbusSignalImpl implements Signal {
|
|||
if (group == null) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return group.getMembers().stream().map(m::resolveSignalServiceAddress).map(SignalServiceAddress::getLegacyIdentifier).collect(Collectors.toList());
|
||||
return group.getMembers()
|
||||
.stream()
|
||||
.map(m::resolveSignalServiceAddress)
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] updateGroup(final byte[] groupId, final String name, final List<String> members, final String avatar) {
|
||||
public byte[] updateGroup(byte[] groupId, String name, List<String> members, String avatar) {
|
||||
try {
|
||||
if (groupId.length == 0) {
|
||||
groupId = null;
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = null;
|
||||
}
|
||||
if (members.isEmpty()) {
|
||||
members = null;
|
||||
}
|
||||
if (avatar.isEmpty()) {
|
||||
avatar = null;
|
||||
}
|
||||
final Pair<byte[], List<SendMessageResult>> results = m.updateGroup(groupId, name, members, avatar);
|
||||
checkSendMessageResults(0, results.second());
|
||||
return results.first();
|
||||
|
|
|
@ -4,6 +4,7 @@ import org.asamk.Signal;
|
|||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -19,9 +20,14 @@ class JsonDataMessage {
|
|||
|
||||
JsonDataMessage(SignalServiceDataMessage dataMessage) {
|
||||
this.timestamp = dataMessage.getTimestamp();
|
||||
if (dataMessage.getGroupContext().isPresent() && dataMessage.getGroupContext().get().getGroupV1().isPresent()) {
|
||||
SignalServiceGroup groupInfo = dataMessage.getGroupContext().get().getGroupV1().get();
|
||||
this.groupInfo = new JsonGroupInfo(groupInfo);
|
||||
if (dataMessage.getGroupContext().isPresent()) {
|
||||
if (dataMessage.getGroupContext().get().getGroupV1().isPresent()) {
|
||||
SignalServiceGroup groupInfo = dataMessage.getGroupContext().get().getGroupV1().get();
|
||||
this.groupInfo = new JsonGroupInfo(groupInfo);
|
||||
} else if (dataMessage.getGroupContext().get().getGroupV2().isPresent()) {
|
||||
SignalServiceGroupV2 groupInfo = dataMessage.getGroupContext().get().getGroupV2().get();
|
||||
this.groupInfo = new JsonGroupInfo(groupInfo);
|
||||
}
|
||||
}
|
||||
if (dataMessage.getBody().isPresent()) {
|
||||
this.message = dataMessage.getBody().get();
|
||||
|
@ -41,19 +47,13 @@ class JsonDataMessage {
|
|||
timestamp = messageReceived.getTimestamp();
|
||||
message = messageReceived.getMessage();
|
||||
groupInfo = new JsonGroupInfo(messageReceived.getGroupId());
|
||||
attachments = messageReceived.getAttachments()
|
||||
.stream()
|
||||
.map(JsonAttachment::new)
|
||||
.collect(Collectors.toList());
|
||||
attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public JsonDataMessage(Signal.SyncMessageReceived messageReceived) {
|
||||
timestamp = messageReceived.getTimestamp();
|
||||
message = messageReceived.getMessage();
|
||||
groupInfo = new JsonGroupInfo(messageReceived.getGroupId());
|
||||
attachments = messageReceived.getAttachments()
|
||||
.stream()
|
||||
.map(JsonAttachment::new)
|
||||
.collect(Collectors.toList());
|
||||
attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package org.asamk.signal.json;
|
||||
|
||||
import org.asamk.signal.manager.GroupUtils;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
|
@ -28,6 +30,11 @@ class JsonGroupInfo {
|
|||
this.type = groupInfo.getType().toString();
|
||||
}
|
||||
|
||||
JsonGroupInfo(SignalServiceGroupV2 groupInfo) {
|
||||
this.groupId = Base64.encodeBytes(GroupUtils.getGroupId(groupInfo.getMasterKey()));
|
||||
this.type = groupInfo.hasSignedGroupChange() ? "UPDATE" : "DELIVER";
|
||||
}
|
||||
|
||||
JsonGroupInfo(byte[] groupId) {
|
||||
this.groupId = Base64.encodeBytes(groupId);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,9 @@ class JsonReceiptMessage {
|
|||
this.timestamps = receiptMessage.getTimestamps();
|
||||
}
|
||||
|
||||
private JsonReceiptMessage(final long when, final boolean isDelivery, final boolean isRead, final List<Long> timestamps) {
|
||||
private JsonReceiptMessage(
|
||||
final long when, final boolean isDelivery, final boolean isRead, final List<Long> timestamps
|
||||
) {
|
||||
this.when = when;
|
||||
this.isDelivery = isDelivery;
|
||||
this.isRead = isRead;
|
||||
|
|
47
src/main/java/org/asamk/signal/manager/GroupUtils.java
Normal file
47
src/main/java/org/asamk/signal/manager/GroupUtils.java
Normal file
|
@ -0,0 +1,47 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.storage.groups.GroupInfo;
|
||||
import org.asamk.signal.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.storage.groups.GroupInfoV2;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
|
||||
public class GroupUtils {
|
||||
|
||||
public static void setGroupContext(
|
||||
final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo groupInfo
|
||||
) {
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
|
||||
.withId(groupInfo.groupId)
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
} else {
|
||||
final GroupInfoV2 groupInfoV2 = (GroupInfoV2) groupInfo;
|
||||
SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
|
||||
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().getRevision())
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] getGroupId(GroupMasterKey groupMasterKey) {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
return groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
|
||||
}
|
||||
|
||||
public static GroupMasterKey deriveV2MigrationMasterKey(byte[] groupId) {
|
||||
try {
|
||||
return new GroupMasterKey(new HKDFv3().deriveSecrets(groupId,
|
||||
"GV2 Migration".getBytes(),
|
||||
GroupMasterKey.SIZE));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,8 +30,7 @@ class SendReceiptAction implements HandleAction {
|
|||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final SendReceiptAction that = (SendReceiptAction) o;
|
||||
return timestamp == that.timestamp &&
|
||||
address.equals(that.address);
|
||||
return timestamp == that.timestamp && address.equals(that.address);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -111,8 +110,7 @@ class SendGroupInfoRequestAction implements HandleAction {
|
|||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final SendGroupInfoRequestAction that = (SendGroupInfoRequestAction) o;
|
||||
return address.equals(that.address) &&
|
||||
Arrays.equals(groupId, that.groupId);
|
||||
return address.equals(that.address) && Arrays.equals(groupId, that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -143,8 +141,7 @@ class SendGroupUpdateAction implements HandleAction {
|
|||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final SendGroupUpdateAction that = (SendGroupUpdateAction) o;
|
||||
return address.equals(that.address) &&
|
||||
Arrays.equals(groupId, that.groupId);
|
||||
return address.equals(that.address) && Arrays.equals(groupId, that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
18
src/main/java/org/asamk/signal/manager/IasTrustStore.java
Normal file
18
src/main/java/org/asamk/signal/manager/IasTrustStore.java
Normal file
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class IasTrustStore implements TrustStore {
|
||||
|
||||
@Override
|
||||
public InputStream getKeyStoreInputStream() {
|
||||
return IasTrustStore.class.getResourceAsStream("ias.store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyStorePassword() {
|
||||
return "whisper";
|
||||
}
|
||||
}
|
|
@ -30,10 +30,6 @@ class KeyUtils {
|
|||
return getSecretBytes(16);
|
||||
}
|
||||
|
||||
static byte[] createUnrestrictedUnidentifiedAccess() {
|
||||
return getSecretBytes(16);
|
||||
}
|
||||
|
||||
static byte[] createStickerUploadKey() {
|
||||
return getSecretBytes(32);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,11 +7,7 @@ public class PathConfig {
|
|||
private final String avatarsPath;
|
||||
|
||||
public static PathConfig createDefault(final String settingsPath) {
|
||||
return new PathConfig(
|
||||
settingsPath + "/data",
|
||||
settingsPath + "/attachments",
|
||||
settingsPath + "/avatars"
|
||||
);
|
||||
return new PathConfig(settingsPath + "/data", settingsPath + "/attachments", settingsPath + "/avatars");
|
||||
}
|
||||
|
||||
private PathConfig(final String dataPath, final String attachmentsPath, final String avatarsPath) {
|
||||
|
|
|
@ -70,12 +70,19 @@ public class ProvisioningManager {
|
|||
public String getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
String deviceUuid = accountManager.getNewDeviceUuid();
|
||||
|
||||
return Utils.createDeviceLinkUri(new Utils.DeviceLinkInfo(deviceUuid, identityKey.getPublicKey().getPublicKey()));
|
||||
return Utils.createDeviceLinkUri(new Utils.DeviceLinkInfo(deviceUuid,
|
||||
identityKey.getPublicKey().getPublicKey()));
|
||||
}
|
||||
|
||||
public String finishDeviceLink(String deviceName) throws IOException, InvalidKeyException, TimeoutException, UserAlreadyExists {
|
||||
String signalingKey = KeyUtils.createSignalingKey();
|
||||
SignalServiceAccountManager.NewDeviceRegistrationReturn ret = accountManager.finishNewDeviceRegistration(identityKey, signalingKey, false, true, registrationId, deviceName);
|
||||
SignalServiceAccountManager.NewDeviceRegistrationReturn ret = accountManager.finishNewDeviceRegistration(
|
||||
identityKey,
|
||||
signalingKey,
|
||||
false,
|
||||
true,
|
||||
registrationId,
|
||||
deviceName);
|
||||
|
||||
String username = ret.getNumber();
|
||||
// TODO do this check before actually registering
|
||||
|
@ -96,7 +103,15 @@ public class ProvisioningManager {
|
|||
}
|
||||
}
|
||||
|
||||
try (SignalAccount account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(), username, ret.getUuid(), password, ret.getDeviceId(), ret.getIdentity(), registrationId, signalingKey, profileKey)) {
|
||||
try (SignalAccount account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(),
|
||||
username,
|
||||
ret.getUuid(),
|
||||
password,
|
||||
ret.getDeviceId(),
|
||||
ret.getIdentity(),
|
||||
registrationId,
|
||||
signalingKey,
|
||||
profileKey)) {
|
||||
account.save();
|
||||
|
||||
try (Manager m = new Manager(account, pathConfig, serviceConfiguration, userAgent)) {
|
||||
|
|
|
@ -13,6 +13,10 @@ import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
|||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
@ -29,12 +33,16 @@ public class ServiceConfig {
|
|||
final static int MAX_ENVELOPE_SIZE = 0;
|
||||
final static long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
|
||||
|
||||
private final static String URL = "https://textsecure-service.whispersystems.org";
|
||||
private final static String CDN_URL = "https://cdn.signal.org";
|
||||
private final static String CDN2_URL = "https://cdn2.signal.org";
|
||||
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api.directory.signal.org";
|
||||
private final static String SIGNAL_KEY_BACKUP_URL = "https://api.backup.signal.org";
|
||||
private final static String STORAGE_URL = "https://storage.signal.org";
|
||||
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
|
||||
private final static TrustStore IAS_TRUST_STORE = new IasTrustStore();
|
||||
|
||||
private final static Optional<Dns> dns = Optional.absent();
|
||||
|
||||
|
@ -57,30 +65,46 @@ public class ServiceConfig {
|
|||
} catch (Throwable ignored) {
|
||||
zkGroupAvailable = false;
|
||||
}
|
||||
capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, false);
|
||||
capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, zkGroupAvailable);
|
||||
}
|
||||
|
||||
public static SignalServiceConfiguration createDefaultServiceConfiguration(String userAgent) {
|
||||
final Interceptor userAgentInterceptor = chain ->
|
||||
chain.proceed(chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.build());
|
||||
final Interceptor userAgentInterceptor = chain -> chain.proceed(chain.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.build());
|
||||
|
||||
final List<Interceptor> interceptors = Collections.singletonList(userAgentInterceptor);
|
||||
|
||||
return new SignalServiceConfiguration(
|
||||
new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
|
||||
makeSignalCdnUrlMapFor(new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)}, new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
|
||||
new SignalContactDiscoveryUrl[0],
|
||||
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
|
||||
makeSignalCdnUrlMapFor(new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
|
||||
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
|
||||
TRUST_STORE)},
|
||||
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
|
||||
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
|
||||
interceptors,
|
||||
dns,
|
||||
zkGroupServerPublicParams
|
||||
);
|
||||
zkGroupServerPublicParams);
|
||||
}
|
||||
|
||||
private static Map<Integer, SignalCdnUrl[]> makeSignalCdnUrlMapFor(SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls) {
|
||||
static KeyStore getIasKeyStore() {
|
||||
try {
|
||||
TrustStore contactTrustStore = IAS_TRUST_STORE;
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("BKS");
|
||||
keyStore.load(contactTrustStore.getKeyStoreInputStream(),
|
||||
contactTrustStore.getKeyStorePassword().toCharArray());
|
||||
|
||||
return keyStore;
|
||||
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<Integer, SignalCdnUrl[]> makeSignalCdnUrlMapFor(
|
||||
SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls
|
||||
) {
|
||||
return Map.of(0, cdn0Urls, 2, cdn2Urls);
|
||||
}
|
||||
|
||||
|
|
|
@ -81,7 +81,21 @@ class Utils {
|
|||
Optional<String> caption = Optional.absent();
|
||||
Optional<String> blurHash = Optional.absent();
|
||||
final Optional<ResumableUploadSpec> resumableUploadSpec = Optional.absent();
|
||||
return new SignalServiceAttachmentStream(attachmentStream, mime, attachmentSize, Optional.of(attachmentFile.getName()), false, false, preview, 0, 0, uploadTimestamp, caption, blurHash, null, null, resumableUploadSpec);
|
||||
return new SignalServiceAttachmentStream(attachmentStream,
|
||||
mime,
|
||||
attachmentSize,
|
||||
Optional.of(attachmentFile.getName()),
|
||||
false,
|
||||
false,
|
||||
preview,
|
||||
0,
|
||||
0,
|
||||
uploadTimestamp,
|
||||
caption,
|
||||
blurHash,
|
||||
null,
|
||||
null,
|
||||
resumableUploadSpec);
|
||||
}
|
||||
|
||||
static StreamDetails createStreamDetailsFromFile(File file) throws IOException {
|
||||
|
@ -96,7 +110,8 @@ class Utils {
|
|||
|
||||
static CertificateValidator getCertificateValidator() {
|
||||
try {
|
||||
ECPublicKey unidentifiedSenderTrustRoot = Curve.decodePoint(Base64.decode(ServiceConfig.UNIDENTIFIED_SENDER_TRUST_ROOT), 0);
|
||||
ECPublicKey unidentifiedSenderTrustRoot = Curve.decodePoint(Base64.decode(ServiceConfig.UNIDENTIFIED_SENDER_TRUST_ROOT),
|
||||
0);
|
||||
return new CertificateValidator(unidentifiedSenderTrustRoot);
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
throw new AssertionError(e);
|
||||
|
@ -116,7 +131,11 @@ class Utils {
|
|||
}
|
||||
|
||||
static String createDeviceLinkUri(DeviceLinkInfo info) {
|
||||
return "tsdevice:/?uuid=" + URLEncoder.encode(info.deviceIdentifier, StandardCharsets.UTF_8) + "&pub_key=" + URLEncoder.encode(Base64.encodeBytesWithoutPadding(info.deviceKey.serialize()), StandardCharsets.UTF_8);
|
||||
return "tsdevice:/?uuid="
|
||||
+ URLEncoder.encode(info.deviceIdentifier, StandardCharsets.UTF_8)
|
||||
+ "&pub_key="
|
||||
+ URLEncoder.encode(Base64.encodeBytesWithoutPadding(info.deviceKey.serialize()),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws IOException, InvalidKeyException {
|
||||
|
@ -180,7 +199,15 @@ class Utils {
|
|||
Optional<SignalServiceAddress> addressOptional = sourceUuid == null && source.isEmpty()
|
||||
? Optional.absent()
|
||||
: Optional.of(new SignalServiceAddress(sourceUuid, source));
|
||||
return new SignalServiceEnvelope(type, addressOptional, sourceDevice, timestamp, legacyMessage, content, serverReceivedTimestamp, serverDeliveredTimestamp, uuid);
|
||||
return new SignalServiceEnvelope(type,
|
||||
addressOptional,
|
||||
sourceDevice,
|
||||
timestamp,
|
||||
legacyMessage,
|
||||
content,
|
||||
serverReceivedTimestamp,
|
||||
serverDeliveredTimestamp,
|
||||
uuid);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,13 +257,18 @@ class Utils {
|
|||
return outputFile;
|
||||
}
|
||||
|
||||
static String computeSafetyNumber(SignalServiceAddress ownAddress, IdentityKey ownIdentityKey, SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) {
|
||||
static String computeSafetyNumber(
|
||||
SignalServiceAddress ownAddress,
|
||||
IdentityKey ownIdentityKey,
|
||||
SignalServiceAddress theirAddress,
|
||||
IdentityKey theirIdentityKey
|
||||
) {
|
||||
int version;
|
||||
byte[] ownId;
|
||||
byte[] theirId;
|
||||
|
||||
if (ServiceConfig.capabilities.isUuid()
|
||||
&& ownAddress.getUuid().isPresent() && theirAddress.getUuid().isPresent()) {
|
||||
if (ServiceConfig.capabilities.isUuid() && ownAddress.getUuid().isPresent() && theirAddress.getUuid()
|
||||
.isPresent()) {
|
||||
// Version 2: UUID user
|
||||
version = 2;
|
||||
ownId = UuidUtil.toByteArray(ownAddress.getUuid().get());
|
||||
|
@ -251,7 +283,11 @@ class Utils {
|
|||
theirId = theirAddress.getNumber().get().getBytes();
|
||||
}
|
||||
|
||||
Fingerprint fingerprint = new NumericFingerprintGenerator(5200).createFor(version, ownId, ownIdentityKey, theirId, theirIdentityKey);
|
||||
Fingerprint fingerprint = new NumericFingerprintGenerator(5200).createFor(version,
|
||||
ownId,
|
||||
ownIdentityKey,
|
||||
theirId,
|
||||
theirIdentityKey);
|
||||
return fingerprint.getDisplayableFingerprint().getDisplayText();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface GroupAuthorizationProvider {
|
||||
|
||||
GroupsV2AuthorizationString getAuthorizationForToday(GroupSecretParams groupSecretParams) throws IOException;
|
||||
}
|
323
src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Normal file
323
src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Normal file
|
@ -0,0 +1,323 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.asamk.signal.storage.groups.GroupInfoV2;
|
||||
import org.asamk.signal.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GroupHelper {
|
||||
|
||||
private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final SelfAddressProvider selfAddressProvider;
|
||||
|
||||
private final GroupsV2Operations groupsV2Operations;
|
||||
|
||||
private final GroupsV2Api groupsV2Api;
|
||||
|
||||
private final GroupAuthorizationProvider groupAuthorizationProvider;
|
||||
|
||||
public GroupHelper(
|
||||
final ProfileKeyCredentialProvider profileKeyCredentialProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final SelfAddressProvider selfAddressProvider,
|
||||
final GroupsV2Operations groupsV2Operations,
|
||||
final GroupsV2Api groupsV2Api,
|
||||
final GroupAuthorizationProvider groupAuthorizationProvider
|
||||
) {
|
||||
this.profileKeyCredentialProvider = profileKeyCredentialProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.selfAddressProvider = selfAddressProvider;
|
||||
this.groupsV2Operations = groupsV2Operations;
|
||||
this.groupsV2Api = groupsV2Api;
|
||||
this.groupAuthorizationProvider = groupAuthorizationProvider;
|
||||
}
|
||||
|
||||
public GroupInfoV2 createGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, String avatarFile
|
||||
) throws IOException {
|
||||
final byte[] avatarBytes = readAvatarBytes(avatarFile);
|
||||
final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes);
|
||||
if (newGroup == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams();
|
||||
|
||||
final GroupsV2AuthorizationString groupAuthForToday;
|
||||
final DecryptedGroup decryptedGroup;
|
||||
try {
|
||||
groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
|
||||
groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
|
||||
decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
System.err.println("Failed to create V2 group: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
if (decryptedGroup == null) {
|
||||
System.err.println("Failed to create V2 group!");
|
||||
return null;
|
||||
}
|
||||
|
||||
final byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
|
||||
final GroupMasterKey masterKey = groupSecretParams.getMasterKey();
|
||||
GroupInfoV2 g = new GroupInfoV2(groupId, masterKey);
|
||||
g.setGroup(decryptedGroup);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private byte[] readAvatarBytes(final String avatarFile) throws IOException {
|
||||
final byte[] avatarBytes;
|
||||
try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
|
||||
avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
|
||||
}
|
||||
return avatarBytes;
|
||||
}
|
||||
|
||||
private GroupsV2Operations.NewGroup buildNewGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, byte[] avatar
|
||||
) {
|
||||
final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
|
||||
selfAddressProvider.getSelfAddress());
|
||||
if (profileKeyCredential == null) {
|
||||
System.err.println("Cannot create a V2 group as self does not have a versioned profile");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!areMembersValid(members)) return null;
|
||||
|
||||
GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
|
||||
Optional.fromNullable(profileKeyCredential));
|
||||
Set<GroupCandidate> candidates = members.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.generate();
|
||||
return groupsV2Operations.createNewGroup(groupSecretParams,
|
||||
name,
|
||||
Optional.fromNullable(avatar),
|
||||
self,
|
||||
candidates,
|
||||
Member.Role.DEFAULT,
|
||||
0);
|
||||
}
|
||||
|
||||
private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
|
||||
final int noUuidCapability = members.stream()
|
||||
.filter(address -> !address.getUuid().isPresent())
|
||||
.collect(Collectors.toUnmodifiableSet())
|
||||
.size();
|
||||
if (noUuidCapability > 0) {
|
||||
System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
|
||||
return false;
|
||||
}
|
||||
|
||||
final int noGv2Capability = members.stream()
|
||||
.map(profileProvider::getProfile)
|
||||
.filter(profile -> profile != null && !profile.getCapabilities().gv2)
|
||||
.collect(Collectors.toUnmodifiableSet())
|
||||
.size();
|
||||
if (noGv2Capability > 0) {
|
||||
System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, String name, String avatarFile
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
GroupChange.Actions.Builder change = name != null
|
||||
? groupOperations.createModifyGroupTitle(name)
|
||||
: GroupChange.Actions.newBuilder();
|
||||
|
||||
if (avatarFile != null) {
|
||||
final byte[] avatarBytes = readAvatarBytes(avatarFile);
|
||||
String avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
|
||||
groupSecretParams,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
|
||||
change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
|
||||
}
|
||||
|
||||
final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, Set<SignalServiceAddress> newMembers
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
if (!areMembersValid(newMembers)) return null;
|
||||
|
||||
Set<GroupCandidate> candidates = newMembers.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final GroupChange.Actions.Builder change = groupOperations.createModifyGroupMembershipChange(candidates,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
|
||||
final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
List<DecryptedPendingMember> pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
|
||||
final UUID selfUuid = selfAddressProvider.getSelfAddress().getUuid().get();
|
||||
Optional<DecryptedPendingMember> selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList,
|
||||
selfUuid);
|
||||
|
||||
if (selfPendingMember.isPresent()) {
|
||||
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
|
||||
} else {
|
||||
return ejectMembers(groupInfoV2, Set.of(selfUuid));
|
||||
}
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
|
||||
final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
|
||||
selfAddress);
|
||||
if (profileKeyCredential == null) {
|
||||
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
|
||||
}
|
||||
|
||||
final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential);
|
||||
|
||||
final Optional<UUID> uuid = selfAddress.getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> revokeInvites(
|
||||
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final Set<UuidCiphertext> uuidCipherTexts = pendingMembers.stream().map(member -> {
|
||||
try {
|
||||
return new UuidCiphertext(member.getUuidCipherText().toByteArray());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
|
||||
}
|
||||
|
||||
private Pair<DecryptedGroup, GroupChange> commitChange(
|
||||
GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final DecryptedGroup previousGroupState = groupInfoV2.getGroup();
|
||||
final int nextRevision = previousGroupState.getRevision() + 1;
|
||||
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
|
||||
final DecryptedGroupChange decryptedChange;
|
||||
final DecryptedGroup decryptedGroupState;
|
||||
|
||||
try {
|
||||
decryptedChange = groupOperations.decryptChange(changeActions,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
GroupChange signedGroupChange = groupsV2Api.patchGroup(change.build(),
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
|
||||
Optional.absent());
|
||||
|
||||
return new Pair<>(decryptedGroupState, signedGroupChange);
|
||||
}
|
||||
|
||||
public DecryptedGroup getUpdatedDecryptedGroup(
|
||||
DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
|
||||
) {
|
||||
try {
|
||||
final DecryptedGroupChange decryptedGroupChange = getDecryptedGroupChange(signedGroupChange,
|
||||
groupMasterKey);
|
||||
if (decryptedGroupChange == null) {
|
||||
return null;
|
||||
}
|
||||
return DecryptedGroupUtil.apply(group, decryptedGroupChange);
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
|
||||
if (signedGroupChange != null) {
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(
|
||||
groupMasterKey));
|
||||
|
||||
try {
|
||||
return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
|
||||
public interface MessagePipeProvider {
|
||||
|
||||
SignalServiceMessagePipe getMessagePipe(boolean unidentified);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
|
||||
public interface MessageReceiverProvider {
|
||||
|
||||
SignalServiceMessageReceiver getMessageReceiver();
|
||||
}
|
123
src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Normal file
123
src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Normal file
|
@ -0,0 +1,123 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class ProfileHelper {
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final UnidentifiedAccessProvider unidentifiedAccessProvider;
|
||||
|
||||
private final MessagePipeProvider messagePipeProvider;
|
||||
|
||||
private final MessageReceiverProvider messageReceiverProvider;
|
||||
|
||||
public ProfileHelper(
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final UnidentifiedAccessProvider unidentifiedAccessProvider,
|
||||
final MessagePipeProvider messagePipeProvider,
|
||||
final MessageReceiverProvider messageReceiverProvider
|
||||
) {
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.unidentifiedAccessProvider = unidentifiedAccessProvider;
|
||||
this.messagePipeProvider = messagePipeProvider;
|
||||
this.messageReceiverProvider = messageReceiverProvider;
|
||||
}
|
||||
|
||||
public ProfileAndCredential retrieveProfileSync(
|
||||
SignalServiceAddress recipient, SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
try {
|
||||
return retrieveProfile(recipient, requestType).get(10, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof PushNetworkException) {
|
||||
throw (PushNetworkException) e.getCause();
|
||||
} else if (e.getCause() instanceof NotFoundException) {
|
||||
throw (NotFoundException) e.getCause();
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ListenableFuture<ProfileAndCredential> retrieveProfile(
|
||||
SignalServiceAddress address, SignalServiceProfile.RequestType requestType
|
||||
) {
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess = getUnidentifiedAccess(address);
|
||||
Optional<ProfileKey> profileKey = Optional.fromNullable(profileKeyProvider.getProfileKey(address));
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
unidentifiedAccess,
|
||||
requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, unidentifiedAccess, requestType),
|
||||
() -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
} else {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
Optional.absent(),
|
||||
requestType), () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
}
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
SignalServiceMessagePipe unidentifiedPipe = messagePipeProvider.getMessagePipe(true);
|
||||
SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent()
|
||||
? unidentifiedPipe
|
||||
: messagePipeProvider.getMessagePipe(false);
|
||||
if (pipe != null) {
|
||||
return pipe.getProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
|
||||
throw new IOException("No pipe available!");
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getSocketRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) {
|
||||
SignalServiceMessageReceiver receiver = messageReceiverProvider.getMessageReceiver();
|
||||
return receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
|
||||
private Optional<UnidentifiedAccess> getUnidentifiedAccess(SignalServiceAddress recipient) {
|
||||
Optional<UnidentifiedAccessPair> unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipient);
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyCredentialProvider {
|
||||
|
||||
ProfileKeyCredential getProfileKeyCredential(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.storage.profiles.SignalProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileProvider {
|
||||
|
||||
SignalProfile getProfile(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface SelfAddressProvider {
|
||||
|
||||
SignalServiceAddress getSelfAddress();
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
public interface SelfProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey();
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.storage.profiles.SignalProfile;
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.util.Util.getSecretBytes;
|
||||
|
||||
public class UnidentifiedAccessHelper {
|
||||
|
||||
private final SelfProfileKeyProvider selfProfileKeyProvider;
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider;
|
||||
|
||||
public UnidentifiedAccessHelper(
|
||||
final SelfProfileKeyProvider selfProfileKeyProvider,
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider
|
||||
) {
|
||||
this.selfProfileKeyProvider = selfProfileKeyProvider;
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.senderCertificateProvider = senderCertificateProvider;
|
||||
}
|
||||
|
||||
public byte[] getSelfUnidentifiedAccessKey() {
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(selfProfileKeyProvider.getProfileKey());
|
||||
}
|
||||
|
||||
public byte[] getTargetUnidentifiedAccessKey(SignalServiceAddress recipient) {
|
||||
ProfileKey theirProfileKey = profileKeyProvider.getProfileKey(recipient);
|
||||
if (theirProfileKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SignalProfile targetProfile = profileProvider.getProfile(recipient);
|
||||
if (targetProfile == null || targetProfile.getUnidentifiedAccess() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetProfile.isUnrestrictedUnidentifiedAccess()) {
|
||||
return createUnrestrictedUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey);
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessForSync() {
|
||||
byte[] selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
byte[] selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (selfUnidentifiedAccessKey == null || selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(selfUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Optional<UnidentifiedAccessPair>> getAccessFor(Collection<SignalServiceAddress> recipients) {
|
||||
return recipients.stream().map(this::getAccessFor).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress recipient) {
|
||||
byte[] recipientUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
|
||||
byte[] selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
byte[] selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (recipientUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(recipientUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createUnrestrictedUnidentifiedAccess() {
|
||||
return getSecretBytes(16);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface UnidentifiedAccessProvider {
|
||||
|
||||
Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
public interface UnidentifiedAccessSenderCertificateProvider {
|
||||
|
||||
byte[] getSenderCertificate();
|
||||
}
|
|
@ -99,7 +99,9 @@ public class SignalAccount implements Closeable {
|
|||
}
|
||||
}
|
||||
|
||||
public static SignalAccount create(String dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey) throws IOException {
|
||||
public static SignalAccount create(
|
||||
String dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
String fileName = getFileName(dataPath, username);
|
||||
if (!new File(fileName).exists()) {
|
||||
|
@ -122,7 +124,17 @@ public class SignalAccount implements Closeable {
|
|||
return account;
|
||||
}
|
||||
|
||||
public static SignalAccount createLinkedAccount(String dataPath, String username, UUID uuid, String password, int deviceId, IdentityKeyPair identityKey, int registrationId, String signalingKey, ProfileKey profileKey) throws IOException {
|
||||
public static SignalAccount createLinkedAccount(
|
||||
String dataPath,
|
||||
String username,
|
||||
UUID uuid,
|
||||
String password,
|
||||
int deviceId,
|
||||
IdentityKeyPair identityKey,
|
||||
int registrationId,
|
||||
String signalingKey,
|
||||
ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
String fileName = getFileName(dataPath, username);
|
||||
if (!new File(fileName).exists()) {
|
||||
|
@ -209,11 +221,14 @@ public class SignalAccount implements Closeable {
|
|||
try {
|
||||
profileKey = new ProfileKey(Base64.decode(Util.getNotNullNode(rootNode, "profileKey").asText()));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException("Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes", e);
|
||||
throw new IOException(
|
||||
"Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
signalProtocolStore = jsonProcessor.convertValue(Util.getNotNullNode(rootNode, "axolotlStore"), JsonSignalProtocolStore.class);
|
||||
signalProtocolStore = jsonProcessor.convertValue(Util.getNotNullNode(rootNode, "axolotlStore"),
|
||||
JsonSignalProtocolStore.class);
|
||||
registered = Util.getNotNullNode(rootNode, "registered").asBoolean();
|
||||
JsonNode groupStoreNode = rootNode.get("groupStore");
|
||||
if (groupStoreNode != null) {
|
||||
|
@ -281,7 +296,8 @@ public class SignalAccount implements Closeable {
|
|||
|
||||
JsonNode threadStoreNode = rootNode.get("threadStore");
|
||||
if (threadStoreNode != null) {
|
||||
LegacyJsonThreadStore threadStore = jsonProcessor.convertValue(threadStoreNode, LegacyJsonThreadStore.class);
|
||||
LegacyJsonThreadStore threadStore = jsonProcessor.convertValue(threadStoreNode,
|
||||
LegacyJsonThreadStore.class);
|
||||
// Migrate thread info to group and contact store
|
||||
for (ThreadInfo thread : threadStore.getThreads()) {
|
||||
if (thread.id == null || thread.id.isEmpty()) {
|
||||
|
@ -326,8 +342,7 @@ public class SignalAccount implements Closeable {
|
|||
.putPOJO("contactStore", contactStore)
|
||||
.putPOJO("recipientStore", recipientStore)
|
||||
.putPOJO("profileStore", profileStore)
|
||||
.putPOJO("stickerStore", stickerStore)
|
||||
;
|
||||
.putPOJO("stickerStore", stickerStore);
|
||||
try {
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
// Write to memory first to prevent corrupting the file in case of serialization errors
|
||||
|
|
|
@ -5,8 +5,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public abstract class GroupInfo {
|
||||
|
||||
|
@ -23,6 +24,16 @@ public abstract class GroupInfo {
|
|||
@JsonIgnore
|
||||
public abstract Set<SignalServiceAddress> getMembers();
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public abstract boolean isBlocked();
|
||||
|
||||
|
@ -34,13 +45,14 @@ public abstract class GroupInfo {
|
|||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
|
||||
Set<SignalServiceAddress> members = new HashSet<>();
|
||||
for (SignalServiceAddress member : getMembers()) {
|
||||
if (!member.matches(address)) {
|
||||
members.add(member);
|
||||
}
|
||||
}
|
||||
return members;
|
||||
return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
|
||||
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
|
||||
.filter(member -> !member.matches(address))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
|
@ -52,4 +64,14 @@ public abstract class GroupInfo {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isPendingMember(SignalServiceAddress address) {
|
||||
for (SignalServiceAddress member : getPendingMembers()) {
|
||||
if (member.matches(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,9 @@ public class GroupInfoV1 extends GroupInfo {
|
|||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
@JsonProperty
|
||||
public byte[] expectedV2Id;
|
||||
|
||||
@JsonProperty
|
||||
public String name;
|
||||
|
||||
|
@ -52,8 +55,21 @@ public class GroupInfoV1 extends GroupInfo {
|
|||
return name;
|
||||
}
|
||||
|
||||
public GroupInfoV1(@JsonProperty("groupId") byte[] groupId, @JsonProperty("name") String name, @JsonProperty("members") Collection<SignalServiceAddress> members, @JsonProperty("avatarId") long _ignored_avatarId, @JsonProperty("color") String color, @JsonProperty("blocked") boolean blocked, @JsonProperty("inboxPosition") Integer inboxPosition, @JsonProperty("archived") boolean archived, @JsonProperty("messageExpirationTime") int messageExpirationTime, @JsonProperty("active") boolean _ignored_active) {
|
||||
public GroupInfoV1(
|
||||
@JsonProperty("groupId") byte[] groupId,
|
||||
@JsonProperty("expectedV2Id") byte[] expectedV2Id,
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("members") Collection<SignalServiceAddress> members,
|
||||
@JsonProperty("avatarId") long _ignored_avatarId,
|
||||
@JsonProperty("color") String color,
|
||||
@JsonProperty("blocked") boolean blocked,
|
||||
@JsonProperty("inboxPosition") Integer inboxPosition,
|
||||
@JsonProperty("archived") boolean archived,
|
||||
@JsonProperty("messageExpirationTime") int messageExpirationTime,
|
||||
@JsonProperty("active") boolean _ignored_active
|
||||
) {
|
||||
super(groupId);
|
||||
this.expectedV2Id = expectedV2Id;
|
||||
this.name = name;
|
||||
this.members.addAll(members);
|
||||
this.color = color;
|
||||
|
@ -123,7 +139,9 @@ public class GroupInfoV1 extends GroupInfo {
|
|||
private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
|
||||
public void serialize(
|
||||
final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeStartArray(value.size());
|
||||
for (SignalServiceAddress address : value) {
|
||||
if (address.getUuid().isPresent()) {
|
||||
|
@ -139,7 +157,9 @@ public class GroupInfoV1 extends GroupInfo {
|
|||
private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
|
|
|
@ -46,7 +46,30 @@ public class GroupInfoV2 extends GroupInfo {
|
|||
if (this.group == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return group.getMembersList().stream()
|
||||
return group.getMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
if (this.group == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return group.getPendingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
if (this.group == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return group.getRequestingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.fasterxml.jackson.databind.SerializerProvider;
|
|||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.asamk.signal.manager.GroupUtils;
|
||||
import org.asamk.signal.util.Hex;
|
||||
import org.asamk.signal.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
|
@ -24,6 +25,7 @@ import java.io.FileInputStream;
|
|||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
@ -48,7 +50,7 @@ public class JsonGroupStore {
|
|||
|
||||
public void updateGroup(GroupInfo group) {
|
||||
groups.put(Base64.encodeBytes(group.groupId), group);
|
||||
if (group instanceof GroupInfoV2) {
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
|
||||
try {
|
||||
IOUtils.createPrivateDirectories(groupCachePath);
|
||||
try (FileOutputStream stream = new FileOutputStream(getGroupFile(group.groupId))) {
|
||||
|
@ -60,8 +62,38 @@ public class JsonGroupStore {
|
|||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(byte[] groupId) {
|
||||
groups.remove(Base64.encodeBytes(groupId));
|
||||
}
|
||||
|
||||
public GroupInfo getGroup(byte[] groupId) {
|
||||
final GroupInfo group = groups.get(Base64.encodeBytes(groupId));
|
||||
if (group == null & groupId.length == 16) {
|
||||
return getGroupByV1Id(groupId);
|
||||
}
|
||||
loadDecryptedGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
public GroupInfo getGroupByV1Id(byte[] groupIdV1) {
|
||||
GroupInfo group = groups.get(Base64.encodeBytes(groupIdV1));
|
||||
if (group == null) {
|
||||
group = groups.get(Base64.encodeBytes(GroupUtils.getGroupId(GroupUtils.deriveV2MigrationMasterKey(groupIdV1))));
|
||||
}
|
||||
loadDecryptedGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
public GroupInfo getGroupByV2Id(byte[] groupIdV2) {
|
||||
GroupInfo group = groups.get(Base64.encodeBytes(groupIdV2));
|
||||
if (group == null) {
|
||||
for (GroupInfo g : groups.values()) {
|
||||
if (g instanceof GroupInfoV1 && Arrays.equals(groupIdV2, ((GroupInfoV1) g).expectedV2Id)) {
|
||||
group = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
loadDecryptedGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
@ -103,7 +135,9 @@ public class JsonGroupStore {
|
|||
private static class GroupsSerializer extends JsonSerializer<Map<String, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public void serialize(final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
|
||||
public void serialize(
|
||||
final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final Collection<GroupInfo> groups = value.values();
|
||||
jgen.writeStartArray(groups.size());
|
||||
for (GroupInfo group : groups) {
|
||||
|
@ -127,7 +161,9 @@ public class JsonGroupStore {
|
|||
private static class GroupsDeserializer extends JsonDeserializer<Map<String, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<String, GroupInfo> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public Map<String, GroupInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<String, GroupInfo> groups = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
|
@ -143,7 +179,11 @@ public class JsonGroupStore {
|
|||
}
|
||||
g.setBlocked(n.get("blocked").asBoolean(false));
|
||||
} else {
|
||||
g = jsonProcessor.treeToValue(n, GroupInfoV1.class);
|
||||
GroupInfoV1 gv1 = jsonProcessor.treeToValue(n, GroupInfoV1.class);
|
||||
if (gv1.expectedV2Id == null) {
|
||||
gv1.expectedV2Id = GroupUtils.getGroupId(GroupUtils.deriveV2MigrationMasterKey(gv1.groupId));
|
||||
}
|
||||
g = gv1;
|
||||
}
|
||||
groups.put(Base64.encodeBytes(g.groupId), g);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
@ -32,7 +33,7 @@ public class ProfileStore {
|
|||
@JsonSerialize(using = ProfileStoreSerializer.class)
|
||||
private final List<SignalProfileEntry> profiles = new ArrayList<>();
|
||||
|
||||
public SignalProfileEntry getProfile(SignalServiceAddress serviceAddress) {
|
||||
public SignalProfileEntry getProfileEntry(SignalServiceAddress serviceAddress) {
|
||||
for (SignalProfileEntry entry : profiles) {
|
||||
if (entry.getServiceAddress().matches(serviceAddress)) {
|
||||
return entry;
|
||||
|
@ -50,8 +51,18 @@ public class ProfileStore {
|
|||
return null;
|
||||
}
|
||||
|
||||
public void updateProfile(SignalServiceAddress serviceAddress, ProfileKey profileKey, long now, SignalProfile profile) {
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress, profileKey, now, profile);
|
||||
public void updateProfile(
|
||||
SignalServiceAddress serviceAddress,
|
||||
ProfileKey profileKey,
|
||||
long now,
|
||||
SignalProfile profile,
|
||||
ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress,
|
||||
profileKey,
|
||||
now,
|
||||
profile,
|
||||
profileKeyCredential);
|
||||
for (int i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
profiles.set(i, newEntry);
|
||||
|
@ -63,7 +74,7 @@ public class ProfileStore {
|
|||
}
|
||||
|
||||
public void storeProfileKey(SignalServiceAddress serviceAddress, ProfileKey profileKey) {
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress, profileKey, 0, null);
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress, profileKey, 0, null, null);
|
||||
for (int i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
if (!profiles.get(i).getProfileKey().equals(profileKey)) {
|
||||
|
@ -79,28 +90,38 @@ public class ProfileStore {
|
|||
public static class ProfileStoreDeserializer extends JsonDeserializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public List<SignalProfileEntry> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public List<SignalProfileEntry> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
List<SignalProfileEntry> addresses = new ArrayList<>();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (JsonNode entry : node) {
|
||||
String name = entry.hasNonNull("name")
|
||||
? entry.get("name").asText()
|
||||
: null;
|
||||
UUID uuid = entry.hasNonNull("uuid")
|
||||
? UuidUtil.parseOrNull(entry.get("uuid").asText())
|
||||
: null;
|
||||
String name = entry.hasNonNull("name") ? entry.get("name").asText() : null;
|
||||
UUID uuid = entry.hasNonNull("uuid") ? UuidUtil.parseOrNull(entry.get("uuid").asText()) : null;
|
||||
final SignalServiceAddress serviceAddress = new SignalServiceAddress(uuid, name);
|
||||
ProfileKey profileKey = null;
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.decode(entry.get("profileKey").asText()));
|
||||
} catch (InvalidInputException ignored) {
|
||||
}
|
||||
ProfileKeyCredential profileKeyCredential = null;
|
||||
if (entry.hasNonNull("profileKeyCredential")) {
|
||||
try {
|
||||
profileKeyCredential = new ProfileKeyCredential(Base64.decode(entry.get(
|
||||
"profileKeyCredential").asText()));
|
||||
} catch (InvalidInputException ignored) {
|
||||
}
|
||||
}
|
||||
long lastUpdateTimestamp = entry.get("lastUpdateTimestamp").asLong();
|
||||
SignalProfile profile = jsonProcessor.treeToValue(entry.get("profile"), SignalProfile.class);
|
||||
addresses.add(new SignalProfileEntry(serviceAddress, profileKey, lastUpdateTimestamp, profile));
|
||||
addresses.add(new SignalProfileEntry(serviceAddress,
|
||||
profileKey,
|
||||
lastUpdateTimestamp,
|
||||
profile,
|
||||
profileKeyCredential));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,7 +132,9 @@ public class ProfileStore {
|
|||
public static class ProfileStoreSerializer extends JsonSerializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public void serialize(List<SignalProfileEntry> profiles, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
List<SignalProfileEntry> profiles, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SignalProfileEntry profileEntry : profiles) {
|
||||
final SignalServiceAddress address = profileEntry.getServiceAddress();
|
||||
|
@ -125,6 +148,10 @@ public class ProfileStore {
|
|||
json.writeStringField("profileKey", Base64.encodeBytes(profileEntry.getProfileKey().serialize()));
|
||||
json.writeNumberField("lastUpdateTimestamp", profileEntry.getLastUpdateTimestamp());
|
||||
json.writeObjectField("profile", profileEntry.getProfile());
|
||||
if (profileEntry.getProfileKeyCredential() != null) {
|
||||
json.writeStringField("profileKeyCredential",
|
||||
Base64.encodeBytes(profileEntry.getProfileKeyCredential().serialize()));
|
||||
}
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
|
|
|
@ -26,7 +26,14 @@ public class SignalProfile {
|
|||
@JsonProperty
|
||||
private final Capabilities capabilities;
|
||||
|
||||
public SignalProfile(final String identityKey, final String name, final File avatarFile, final String unidentifiedAccess, final boolean unrestrictedUnidentifiedAccess, final SignalServiceProfile.Capabilities capabilities) {
|
||||
public SignalProfile(
|
||||
final String identityKey,
|
||||
final String name,
|
||||
final File avatarFile,
|
||||
final String unidentifiedAccess,
|
||||
final boolean unrestrictedUnidentifiedAccess,
|
||||
final SignalServiceProfile.Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.avatarFile = avatarFile;
|
||||
|
@ -38,7 +45,13 @@ public class SignalProfile {
|
|||
this.capabilities.gv2 = capabilities.isGv2();
|
||||
}
|
||||
|
||||
public SignalProfile(@JsonProperty("identityKey") final String identityKey, @JsonProperty("name") final String name, @JsonProperty("unidentifiedAccess") final String unidentifiedAccess, @JsonProperty("unrestrictedUnidentifiedAccess") final boolean unrestrictedUnidentifiedAccess, @JsonProperty("capabilities") final Capabilities capabilities) {
|
||||
public SignalProfile(
|
||||
@JsonProperty("identityKey") final String identityKey,
|
||||
@JsonProperty("name") final String name,
|
||||
@JsonProperty("unidentifiedAccess") final String unidentifiedAccess,
|
||||
@JsonProperty("unrestrictedUnidentifiedAccess") final boolean unrestrictedUnidentifiedAccess,
|
||||
@JsonProperty("capabilities") final Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.avatarFile = null;
|
||||
|
@ -73,14 +86,23 @@ public class SignalProfile {
|
|||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SignalProfile{" +
|
||||
"identityKey='" + identityKey + '\'' +
|
||||
", name='" + name + '\'' +
|
||||
", avatarFile=" + avatarFile +
|
||||
", unidentifiedAccess='" + unidentifiedAccess + '\'' +
|
||||
", unrestrictedUnidentifiedAccess=" + unrestrictedUnidentifiedAccess +
|
||||
", capabilities=" + capabilities +
|
||||
'}';
|
||||
return "SignalProfile{"
|
||||
+ "identityKey='"
|
||||
+ identityKey
|
||||
+ '\''
|
||||
+ ", name='"
|
||||
+ name
|
||||
+ '\''
|
||||
+ ", avatarFile="
|
||||
+ avatarFile
|
||||
+ ", unidentifiedAccess='"
|
||||
+ unidentifiedAccess
|
||||
+ '\''
|
||||
+ ", unrestrictedUnidentifiedAccess="
|
||||
+ unrestrictedUnidentifiedAccess
|
||||
+ ", capabilities="
|
||||
+ capabilities
|
||||
+ '}';
|
||||
}
|
||||
|
||||
public static class Capabilities {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.asamk.signal.storage.profiles;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public class SignalProfileEntry {
|
||||
|
@ -13,11 +14,22 @@ public class SignalProfileEntry {
|
|||
|
||||
private final SignalProfile profile;
|
||||
|
||||
public SignalProfileEntry(final SignalServiceAddress serviceAddress, final ProfileKey profileKey, final long lastUpdateTimestamp, final SignalProfile profile) {
|
||||
private final ProfileKeyCredential profileKeyCredential;
|
||||
|
||||
private boolean requestPending;
|
||||
|
||||
public SignalProfileEntry(
|
||||
final SignalServiceAddress serviceAddress,
|
||||
final ProfileKey profileKey,
|
||||
final long lastUpdateTimestamp,
|
||||
final SignalProfile profile,
|
||||
final ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
this.serviceAddress = serviceAddress;
|
||||
this.profileKey = profileKey;
|
||||
this.lastUpdateTimestamp = lastUpdateTimestamp;
|
||||
this.profile = profile;
|
||||
this.profileKeyCredential = profileKeyCredential;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getServiceAddress() {
|
||||
|
@ -35,4 +47,16 @@ public class SignalProfileEntry {
|
|||
public SignalProfile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public ProfileKeyCredential getProfileKeyCredential() {
|
||||
return profileKeyCredential;
|
||||
}
|
||||
|
||||
public boolean isRequestPending() {
|
||||
return requestPending;
|
||||
}
|
||||
|
||||
public void setRequestPending(final boolean requestPending) {
|
||||
this.requestPending = requestPending;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,7 +63,10 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
|
||||
@Override
|
||||
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
|
||||
return saveIdentity(resolveSignalServiceAddress(address.getName()), identityKey, TrustLevel.TRUSTED_UNVERIFIED, null);
|
||||
return saveIdentity(resolveSignalServiceAddress(address.getName()),
|
||||
identityKey,
|
||||
TrustLevel.TRUSTED_UNVERIFIED,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,7 +78,9 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
* @param added Added timestamp, if null and the key is newly added, the current time is used.
|
||||
*/
|
||||
public boolean saveIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel, Date added) {
|
||||
public boolean saveIdentity(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel, Date added
|
||||
) {
|
||||
for (Identity id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
|
@ -99,7 +104,9 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
* @param identityKey The user's public key
|
||||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
*/
|
||||
public void setIdentityTrustLevel(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel) {
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
for (Identity id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
|
@ -178,7 +185,9 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
public static class JsonIdentityKeyStoreDeserializer extends JsonDeserializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonIdentityKeyStore deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public JsonIdentityKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
try {
|
||||
|
@ -190,25 +199,24 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
JsonNode trustedKeysNode = node.get("trustedKeys");
|
||||
if (trustedKeysNode.isArray()) {
|
||||
for (JsonNode trustedKey : trustedKeysNode) {
|
||||
String trustedKeyName = trustedKey.hasNonNull("name")
|
||||
? trustedKey.get("name").asText()
|
||||
: null;
|
||||
String trustedKeyName = trustedKey.hasNonNull("name") ? trustedKey.get("name").asText() : null;
|
||||
|
||||
if (UuidUtil.isUuid(trustedKeyName)) {
|
||||
// Ignore identities that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
UUID uuid = trustedKey.hasNonNull("uuid")
|
||||
? UuidUtil.parseOrNull(trustedKey.get("uuid").asText())
|
||||
: null;
|
||||
UUID uuid = trustedKey.hasNonNull("uuid") ? UuidUtil.parseOrNull(trustedKey.get("uuid")
|
||||
.asText()) : null;
|
||||
final SignalServiceAddress serviceAddress = uuid == null
|
||||
? Util.getSignalServiceAddressFromIdentifier(trustedKeyName)
|
||||
: new SignalServiceAddress(uuid, trustedKeyName);
|
||||
try {
|
||||
IdentityKey id = new IdentityKey(Base64.decode(trustedKey.get("identityKey").asText()), 0);
|
||||
TrustLevel trustLevel = trustedKey.has("trustLevel") ? TrustLevel.fromInt(trustedKey.get("trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
|
||||
Date added = trustedKey.has("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp").asLong()) : new Date();
|
||||
TrustLevel trustLevel = trustedKey.has("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
|
||||
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
|
||||
Date added = trustedKey.has("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
|
||||
.asLong()) : new Date();
|
||||
keyStore.saveIdentity(serviceAddress, id, trustLevel, added);
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
System.out.println(String.format("Error while decoding key for: %s", trustedKeyName));
|
||||
|
@ -226,10 +234,13 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
public static class JsonIdentityKeyStoreSerializer extends JsonSerializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(JsonIdentityKeyStore jsonIdentityKeyStore, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
JsonIdentityKeyStore jsonIdentityKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("registrationId", jsonIdentityKeyStore.getLocalRegistrationId());
|
||||
json.writeStringField("identityKey", Base64.encodeBytes(jsonIdentityKeyStore.getIdentityKeyPair().serialize()));
|
||||
json.writeStringField("identityKey",
|
||||
Base64.encodeBytes(jsonIdentityKeyStore.getIdentityKeyPair().serialize()));
|
||||
json.writeArrayFieldStart("trustedKeys");
|
||||
for (Identity trustedKey : jsonIdentityKeyStore.identities) {
|
||||
json.writeStartObject();
|
||||
|
@ -279,8 +290,7 @@ public class JsonIdentityKeyStore implements IdentityKeyStore {
|
|||
}
|
||||
|
||||
boolean isTrusted() {
|
||||
return trustLevel == TrustLevel.TRUSTED_UNVERIFIED ||
|
||||
trustLevel == TrustLevel.TRUSTED_VERIFIED;
|
||||
return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED;
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
|
|
|
@ -60,7 +60,9 @@ class JsonPreKeyStore implements PreKeyStore {
|
|||
public static class JsonPreKeyStoreDeserializer extends JsonDeserializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonPreKeyStore deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public JsonPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Map<Integer, byte[]> preKeyMap = new HashMap<>();
|
||||
|
@ -86,7 +88,9 @@ class JsonPreKeyStore implements PreKeyStore {
|
|||
public static class JsonPreKeyStoreSerializer extends JsonSerializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(JsonPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
JsonPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (Map.Entry<Integer, byte[]> preKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
|
|
|
@ -126,24 +126,22 @@ class JsonSessionStore implements SessionStore {
|
|||
public static class JsonSessionStoreDeserializer extends JsonDeserializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public JsonSessionStore deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public JsonSessionStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
JsonSessionStore sessionStore = new JsonSessionStore();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (JsonNode session : node) {
|
||||
String sessionName = session.hasNonNull("name")
|
||||
? session.get("name").asText()
|
||||
: null;
|
||||
String sessionName = session.hasNonNull("name") ? session.get("name").asText() : null;
|
||||
if (UuidUtil.isUuid(sessionName)) {
|
||||
// Ignore sessions that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
UUID uuid = session.hasNonNull("uuid")
|
||||
? UuidUtil.parseOrNull(session.get("uuid").asText())
|
||||
: null;
|
||||
UUID uuid = session.hasNonNull("uuid") ? UuidUtil.parseOrNull(session.get("uuid").asText()) : null;
|
||||
final SignalServiceAddress serviceAddress = uuid == null
|
||||
? Util.getSignalServiceAddressFromIdentifier(sessionName)
|
||||
: new SignalServiceAddress(uuid, sessionName);
|
||||
|
@ -165,7 +163,9 @@ class JsonSessionStore implements SessionStore {
|
|||
public static class JsonSessionStoreSerializer extends JsonSerializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(JsonSessionStore jsonSessionStore, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
JsonSessionStore jsonSessionStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SessionInfo sessionInfo : jsonSessionStore.sessions) {
|
||||
json.writeStartObject();
|
||||
|
|
|
@ -42,7 +42,12 @@ public class JsonSignalProtocolStore implements SignalProtocolStore {
|
|||
public JsonSignalProtocolStore() {
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore(JsonPreKeyStore preKeyStore, JsonSessionStore sessionStore, JsonSignedPreKeyStore signedPreKeyStore, JsonIdentityKeyStore identityKeyStore) {
|
||||
public JsonSignalProtocolStore(
|
||||
JsonPreKeyStore preKeyStore,
|
||||
JsonSessionStore sessionStore,
|
||||
JsonSignedPreKeyStore signedPreKeyStore,
|
||||
JsonIdentityKeyStore identityKeyStore
|
||||
) {
|
||||
this.preKeyStore = preKeyStore;
|
||||
this.sessionStore = sessionStore;
|
||||
this.signedPreKeyStore = signedPreKeyStore;
|
||||
|
@ -80,7 +85,9 @@ public class JsonSignalProtocolStore implements SignalProtocolStore {
|
|||
identityKeyStore.saveIdentity(serviceAddress, identityKey, trustLevel, null);
|
||||
}
|
||||
|
||||
public void setIdentityTrustLevel(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel) {
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
identityKeyStore.setIdentityTrustLevel(serviceAddress, identityKey, trustLevel);
|
||||
}
|
||||
|
||||
|
|
|
@ -77,7 +77,9 @@ class JsonSignedPreKeyStore implements SignedPreKeyStore {
|
|||
public static class JsonSignedPreKeyStoreDeserializer extends JsonDeserializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonSignedPreKeyStore deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public JsonSignedPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Map<Integer, byte[]> preKeyMap = new HashMap<>();
|
||||
|
@ -103,7 +105,9 @@ class JsonSignedPreKeyStore implements SignedPreKeyStore {
|
|||
public static class JsonSignedPreKeyStoreSerializer extends JsonSerializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(JsonSignedPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
JsonSignedPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (Map.Entry<Integer, byte[]> signedPreKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
|
|
|
@ -49,7 +49,9 @@ public class RecipientStore {
|
|||
public static class RecipientStoreDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
|
@ -70,7 +72,9 @@ public class RecipientStore {
|
|||
public static class RecipientStoreSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(Set<SignalServiceAddress> addresses, JsonGenerator json, SerializerProvider serializerProvider) throws IOException {
|
||||
public void serialize(
|
||||
Set<SignalServiceAddress> addresses, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SignalServiceAddress address : addresses) {
|
||||
json.writeStartObject();
|
||||
|
|
|
@ -37,7 +37,9 @@ public class StickerStore {
|
|||
private static class StickersSerializer extends JsonSerializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public void serialize(final Map<byte[], Sticker> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
|
||||
public void serialize(
|
||||
final Map<byte[], Sticker> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final Collection<Sticker> stickers = value.values();
|
||||
jgen.writeStartArray(stickers.size());
|
||||
for (Sticker sticker : stickers) {
|
||||
|
@ -54,7 +56,9 @@ public class StickerStore {
|
|||
private static class StickersDeserializer extends JsonDeserializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public Map<byte[], Sticker> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public Map<byte[], Sticker> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<byte[], Sticker> stickers = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
|
|
|
@ -34,7 +34,9 @@ public class LegacyJsonThreadStore {
|
|||
private static class MapToListSerializer extends JsonSerializer<Map<?, ?>> {
|
||||
|
||||
@Override
|
||||
public void serialize(final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
|
||||
public void serialize(
|
||||
final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeObject(value.values());
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +44,9 @@ public class LegacyJsonThreadStore {
|
|||
private static class ThreadsDeserializer extends JsonDeserializer<Map<String, ThreadInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<String, ThreadInfo> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
public Map<String, ThreadInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<String, ThreadInfo> threads = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
|
|
|
@ -17,7 +17,8 @@ public class ErrorUtils {
|
|||
public static void handleAssertionError(AssertionError e) {
|
||||
System.err.println("Failed to send/receive message (Assertion): " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
System.err.println("If you use an Oracle JRE please check if you have unlimited strength crypto enabled, see README");
|
||||
System.err.println(
|
||||
"If you use an Oracle JRE please check if you have unlimited strength crypto enabled, see README");
|
||||
}
|
||||
|
||||
public static int handleTimestampAndSendMessageResults(long timestamp, List<SendMessageResult> results) {
|
||||
|
|
|
@ -2,9 +2,7 @@ package org.asamk.signal.util;
|
|||
|
||||
public class Hex {
|
||||
|
||||
private final static char[] HEX_DIGITS = {
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
|
||||
};
|
||||
private final static char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
|
||||
|
||||
private Hex() {
|
||||
}
|
||||
|
|
|
@ -16,10 +16,13 @@ public class SecurityProvider extends Provider {
|
|||
|
||||
// Workaround for BKS truststore
|
||||
put("KeyStore.BKS", org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi.Std.class.getCanonicalName());
|
||||
put("KeyStore.BKS-V1", org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi.Version1.class.getCanonicalName());
|
||||
put("KeyStore.BouncyCastle", org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi.BouncyCastleStore.class.getCanonicalName());
|
||||
put("KeyStore.BKS-V1",
|
||||
org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi.Version1.class.getCanonicalName());
|
||||
put("KeyStore.BouncyCastle",
|
||||
org.bouncycastle.jcajce.provider.keystore.bc.BcKeyStoreSpi.BouncyCastleStore.class.getCanonicalName());
|
||||
put("KeyFactory.X.509", org.bouncycastle.jcajce.provider.asymmetric.x509.KeyFactory.class.getCanonicalName());
|
||||
put("CertificateFactory.X.509", org.bouncycastle.jcajce.provider.asymmetric.x509.CertificateFactory.class.getCanonicalName());
|
||||
put("CertificateFactory.X.509",
|
||||
org.bouncycastle.jcajce.provider.asymmetric.x509.CertificateFactory.class.getCanonicalName());
|
||||
}
|
||||
|
||||
public static class DefaultRandom extends SecureRandomSpi {
|
||||
|
|
|
@ -41,7 +41,8 @@ public class Util {
|
|||
public static JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException {
|
||||
JsonNode node = parent.get(name);
|
||||
if (node == null) {
|
||||
throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ", name));
|
||||
throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ",
|
||||
name));
|
||||
}
|
||||
|
||||
return node;
|
||||
|
|
BIN
src/main/resources/org/asamk/signal/manager/ias.store
Normal file
BIN
src/main/resources/org/asamk/signal/manager/ias.store
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue