diff --git a/.gitignore b/.gitignore index 3dc9875b..8fa9c8bd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ local.properties .project .settings/ out/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af850b1..be5b5ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +### Added +- `--verbose` flag to increase log level ### Fixed - Disable registration lock before removing the PIN diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index fa2db7c3..39585668 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -21,6 +21,9 @@ For registering you need a phone number where you can receive SMS or incoming ca signal-cli was primarily developed to be used on servers to notify admins of important events. For this use-case, it has a dbus interface, that can be used to send messages from any programming language that has dbus bindings. +For some functionality the Signal protocol requires that all messages have been received from the server. +The `receive` command should be regularly executed. In daemon mode messages are continuously received. + == Options *-h*, *--help*:: @@ -29,6 +32,9 @@ Show help message and quit. *-v*, *--version*:: Print the version and quit. +*--verbose*:: +Raise log level and include lib signal logs. + *--config* CONFIG:: Set the path, where to store the config. Make sure you have full read/write access to the given directory. @@ -44,6 +50,9 @@ Make request via user dbus. *--dbus-system*:: Make request via system dbus. +*-o* OUTPUT-MODE, *--output* OUTPUT-MODE:: +Specify if you want commands to output in either "plain-text" mode or in "json". Defaults to "plain-text" + == Commands === register @@ -97,7 +106,8 @@ Remove the registration lock pin. === link Link to an existing device, instead of registering a new number. -This shows a "tsdevice:/…" URI. If you want to connect to another signal-cli instance, you can just use this URI. If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app. +This shows a "tsdevice:/…" URI. If you want to connect to another signal-cli instance, you can just use this URI. +If you want to link to an Android/iOS device, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app. *-n* NAME, *--name* NAME:: Optionally specify a name to describe this new device. @@ -109,7 +119,8 @@ Link another device to this device. Only works, if this is the master device. *--uri* URI:: -Specify the uri contained in the QR code shown by the new device. You will need the full uri enclosed in quotation marks, such as "tsdevice:/?uuid=....." +Specify the uri contained in the QR code shown by the new device. +You will need the full uri enclosed in quotation marks, such as "tsdevice:/?uuid=....." === listDevices @@ -126,12 +137,12 @@ Use listDevices to see the deviceIds. === getUserStatus -Uses a list of phone numbers to determine the statuses of those users. Shows if they are registered on the Signal Servers or not. +Uses a list of phone numbers to determine the statuses of those users. +Shows if they are registered on the Signal Servers or not. +In json mode this is outputted as a list of objects. [NUMBER [NUMBER ...]]:: One or more numbers to check. -*--json*:: -Output the statuses as an array of json objects. === send @@ -177,15 +188,14 @@ Remove a reaction. === receive Query the server for new messages. -New messages are printed on standardoutput and attachments are downloaded to the config directory. +New messages are printed on standard output and attachments are downloaded to the config directory. +In json mode this is outputted as one json object per line. *-t* TIMEOUT, *--timeout* TIMEOUT:: Number of seconds to wait for new messages (negative values disable timeout). Default is 5 seconds. *--ignore-attachments*:: Don’t download attachments of received messages. -*--json*:: -Output received messages in json format, one object per line. === joinGroup @@ -222,10 +232,11 @@ Specify the recipient group ID in base64 encoding. === listGroups -Show a list of known groups. +Show a list of known groups and related information. +In json mode this is outputted as an list of objects and is always in detailed mode. *-d*, *--detailed*:: -Include the list of members of each group. +Include the list of members of each group and the group invite link. === listIdentities diff --git a/src/main/java/org/asamk/signal/Cli.java b/src/main/java/org/asamk/signal/Cli.java new file mode 100644 index 00000000..a0349a31 --- /dev/null +++ b/src/main/java/org/asamk/signal/Cli.java @@ -0,0 +1,206 @@ +package org.asamk.signal; + +import net.sourceforge.argparse4j.inf.Namespace; + +import org.asamk.Signal; +import org.asamk.signal.commands.Command; +import org.asamk.signal.commands.Commands; +import org.asamk.signal.commands.DbusCommand; +import org.asamk.signal.commands.ExtendedDbusCommand; +import org.asamk.signal.commands.LocalCommand; +import org.asamk.signal.commands.ProvisioningCommand; +import org.asamk.signal.commands.RegistrationCommand; +import org.asamk.signal.dbus.DbusSignalImpl; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.NotRegisteredException; +import org.asamk.signal.manager.ProvisioningManager; +import org.asamk.signal.manager.RegistrationManager; +import org.asamk.signal.manager.ServiceConfig; +import org.asamk.signal.util.IOUtils; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.exceptions.DBusException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; + +import java.io.File; +import java.io.IOException; + +public class Cli { + + private final static Logger logger = LoggerFactory.getLogger(Main.class); + + private final Namespace ns; + + public Cli(final Namespace ns) { + this.ns = ns; + } + + public int init() { + Command command = getCommand(); + if (command == null) { + logger.error("Command not implemented!"); + return 2; + } + + if (ns.getBoolean("dbus") || ns.getBoolean("dbus_system")) { + return initDbusClient(command, ns.getBoolean("dbus_system")); + } + + final String username = ns.getString("username"); + + final File dataPath; + String config = ns.getString("config"); + if (config != null) { + dataPath = new File(config); + } else { + dataPath = getDefaultDataPath(); + } + + final SignalServiceConfiguration serviceConfiguration = ServiceConfig.createDefaultServiceConfiguration( + BaseConfig.USER_AGENT); + + if (!ServiceConfig.getCapabilities().isGv2()) { + logger.warn("WARNING: Support for new group V2 is disabled," + + " because the required native library dependency is missing: libzkgroup"); + } + + if (username == null) { + ProvisioningManager pm = new ProvisioningManager(dataPath, serviceConfiguration, BaseConfig.USER_AGENT); + return handleCommand(command, pm); + } + + if (command instanceof RegistrationCommand) { + final RegistrationManager manager; + try { + manager = RegistrationManager.init(username, dataPath, serviceConfiguration, BaseConfig.USER_AGENT); + } catch (Throwable e) { + logger.error("Error loading or creating state file: {}", e.getMessage()); + return 1; + } + try (RegistrationManager m = manager) { + return handleCommand(command, m); + } catch (Exception e) { + logger.error("Cleanup failed", e); + return 2; + } + } + + Manager manager; + try { + manager = Manager.init(username, dataPath, serviceConfiguration, BaseConfig.USER_AGENT); + } catch (NotRegisteredException e) { + System.err.println("User is not registered."); + return 0; + } catch (Throwable e) { + logger.error("Error loading state file: {}", e.getMessage()); + return 1; + } + + try (Manager m = manager) { + try { + m.checkAccountState(); + } catch (IOException e) { + logger.error("Error while checking account: {}", e.getMessage()); + return 1; + } + + return handleCommand(command, m); + } catch (IOException e) { + logger.error("Cleanup failed", e); + return 2; + } + } + + private Command getCommand() { + String commandKey = ns.getString("command"); + return Commands.getCommand(commandKey); + } + + private int initDbusClient(final Command command, final boolean systemBus) { + try { + DBusConnection.DBusBusType busType; + if (systemBus) { + busType = DBusConnection.DBusBusType.SYSTEM; + } else { + busType = DBusConnection.DBusBusType.SESSION; + } + try (DBusConnection dBusConn = DBusConnection.getConnection(busType)) { + Signal ts = dBusConn.getRemoteObject(DbusConfig.SIGNAL_BUSNAME, + DbusConfig.SIGNAL_OBJECTPATH, + Signal.class); + + return handleCommand(command, ts, dBusConn); + } + } catch (DBusException | IOException e) { + logger.error("Dbus client failed", e); + return 2; + } + } + + private int handleCommand(Command command, Signal ts, DBusConnection dBusConn) { + if (command instanceof ExtendedDbusCommand) { + return ((ExtendedDbusCommand) command).handleCommand(ns, ts, dBusConn); + } else if (command instanceof DbusCommand) { + return ((DbusCommand) command).handleCommand(ns, ts); + } else { + System.err.println("Command is not yet implemented via dbus"); + return 1; + } + } + + private int handleCommand(Command command, ProvisioningManager pm) { + if (command instanceof ProvisioningCommand) { + return ((ProvisioningCommand) command).handleCommand(ns, pm); + } else { + System.err.println("Command only works with a username"); + return 1; + } + } + + private int handleCommand(Command command, RegistrationManager m) { + if (command instanceof RegistrationCommand) { + return ((RegistrationCommand) command).handleCommand(ns, m); + } + return 1; + } + + private int handleCommand(Command command, Manager m) { + if (command instanceof LocalCommand) { + return ((LocalCommand) command).handleCommand(ns, m); + } else if (command instanceof DbusCommand) { + return ((DbusCommand) command).handleCommand(ns, new DbusSignalImpl(m)); + } else { + System.err.println("Command only works via dbus"); + return 1; + } + } + + /** + * Uses $XDG_DATA_HOME/signal-cli if it exists, or if none of the legacy directories exist: + * - $HOME/.config/signal + * - $HOME/.config/textsecure + * + * @return the data directory to be used by signal-cli. + */ + private static File getDefaultDataPath() { + File dataPath = new File(IOUtils.getDataHomeDir(), "signal-cli"); + if (dataPath.exists()) { + return dataPath; + } + + File configPath = new File(System.getProperty("user.home"), ".config"); + + File legacySettingsPath = new File(configPath, "signal"); + if (legacySettingsPath.exists()) { + return legacySettingsPath; + } + + legacySettingsPath = new File(configPath, "textsecure"); + if (legacySettingsPath.exists()) { + return legacySettingsPath; + } + + return dataPath; + } +} diff --git a/src/main/java/org/asamk/signal/Main.java b/src/main/java/org/asamk/signal/Main.java index 5b0ff682..8335d085 100644 --- a/src/main/java/org/asamk/signal/Main.java +++ b/src/main/java/org/asamk/signal/Main.java @@ -25,227 +25,62 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import net.sourceforge.argparse4j.inf.Subparsers; -import org.asamk.Signal; import org.asamk.signal.commands.Command; import org.asamk.signal.commands.Commands; -import org.asamk.signal.commands.DbusCommand; -import org.asamk.signal.commands.ExtendedDbusCommand; -import org.asamk.signal.commands.LocalCommand; -import org.asamk.signal.commands.ProvisioningCommand; -import org.asamk.signal.commands.RegistrationCommand; -import org.asamk.signal.dbus.DbusSignalImpl; -import org.asamk.signal.manager.Manager; -import org.asamk.signal.manager.NotRegisteredException; -import org.asamk.signal.manager.ProvisioningManager; -import org.asamk.signal.manager.RegistrationManager; -import org.asamk.signal.manager.ServiceConfig; -import org.asamk.signal.util.IOUtils; +import org.asamk.signal.manager.LibSignalLogger; import org.asamk.signal.util.SecurityProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.freedesktop.dbus.connections.impl.DBusConnection; -import org.freedesktop.dbus.exceptions.DBusException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; -import java.io.File; -import java.io.IOException; import java.security.Security; import java.util.Map; public class Main { - final static Logger logger = LoggerFactory.getLogger(Main.class); - public static void main(String[] args) { installSecurityProviderWorkaround(); + // Configuring the logger needs to happen before any logger is initialized + if (isVerbose(args)) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "trace"); + System.setProperty("org.slf4j.simpleLogger.showThreadName", "true"); + System.setProperty("org.slf4j.simpleLogger.showShortLogName", "false"); + System.setProperty("org.slf4j.simpleLogger.showDateTime", "true"); + System.setProperty("org.slf4j.simpleLogger.dateTimeFormat", "yyyy-MM-dd'T'HH:mm:ss.SSSXX"); + LibSignalLogger.initLogger(); + } else { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "info"); + System.setProperty("org.slf4j.simpleLogger.showThreadName", "false"); + System.setProperty("org.slf4j.simpleLogger.showShortLogName", "true"); + System.setProperty("org.slf4j.simpleLogger.showDateTime", "false"); + } + Namespace ns = parseArgs(args); if (ns == null) { System.exit(1); } - int res = init(ns); + int res = new Cli(ns).init(); System.exit(res); } - public static void installSecurityProviderWorkaround() { + private static void installSecurityProviderWorkaround() { // Register our own security provider Security.insertProviderAt(new SecurityProvider(), 1); Security.addProvider(new BouncyCastleProvider()); } - public static int init(Namespace ns) { - Command command = getCommand(ns); - if (command == null) { - logger.error("Command not implemented!"); - return 3; - } + private static boolean isVerbose(String[] args) { + ArgumentParser parser = buildBaseArgumentParser(); - if (ns.getBoolean("dbus") || ns.getBoolean("dbus_system")) { - return initDbusClient(command, ns, ns.getBoolean("dbus_system")); - } - - final String username = ns.getString("username"); - - final File dataPath; - String config = ns.getString("config"); - if (config != null) { - dataPath = new File(config); - } else { - dataPath = getDefaultDataPath(); - } - - final SignalServiceConfiguration serviceConfiguration = ServiceConfig.createDefaultServiceConfiguration( - BaseConfig.USER_AGENT); - - if (!ServiceConfig.getCapabilities().isGv2()) { - logger.warn("WARNING: Support for new group V2 is disabled," - + " because the required native library dependency is missing: libzkgroup"); - } - - if (username == null) { - ProvisioningManager pm = new ProvisioningManager(dataPath, serviceConfiguration, BaseConfig.USER_AGENT); - return handleCommand(command, ns, pm); - } - - if (command instanceof RegistrationCommand) { - final RegistrationManager manager; - try { - manager = RegistrationManager.init(username, dataPath, serviceConfiguration, BaseConfig.USER_AGENT); - } catch (Throwable e) { - logger.error("Error loading or creating state file: {}", e.getMessage()); - return 2; - } - try (RegistrationManager m = manager) { - return handleCommand(command, ns, m); - } catch (Exception e) { - logger.error("Cleanup failed", e); - return 3; - } - } - - Manager manager; + Namespace ns; try { - manager = Manager.init(username, dataPath, serviceConfiguration, BaseConfig.USER_AGENT); - } catch (NotRegisteredException e) { - System.err.println("User is not registered."); - return 1; - } catch (Throwable e) { - logger.error("Error loading state file: {}", e.getMessage()); - return 2; + ns = parser.parseKnownArgs(args, null); + } catch (ArgumentParserException e) { + return false; } - try (Manager m = manager) { - try { - m.checkAccountState(); - } catch (IOException e) { - logger.error("Error while checking account: {}", e.getMessage()); - return 2; - } - - return handleCommand(command, ns, m); - } catch (IOException e) { - logger.error("Cleanup failed", e); - return 3; - } - } - - private static int initDbusClient(final Command command, final Namespace ns, final boolean systemBus) { - try { - DBusConnection.DBusBusType busType; - if (systemBus) { - busType = DBusConnection.DBusBusType.SYSTEM; - } else { - busType = DBusConnection.DBusBusType.SESSION; - } - try (DBusConnection dBusConn = DBusConnection.getConnection(busType)) { - Signal ts = dBusConn.getRemoteObject(DbusConfig.SIGNAL_BUSNAME, - DbusConfig.SIGNAL_OBJECTPATH, - Signal.class); - - return handleCommand(command, ns, ts, dBusConn); - } - } catch (DBusException | IOException e) { - logger.error("Dbus client failed", e); - return 3; - } - } - - private static Command getCommand(Namespace ns) { - String commandKey = ns.getString("command"); - final Map commands = Commands.getCommands(); - if (!commands.containsKey(commandKey)) { - return null; - } - return commands.get(commandKey); - } - - private static int handleCommand(Command command, Namespace ns, Signal ts, DBusConnection dBusConn) { - if (command instanceof ExtendedDbusCommand) { - return ((ExtendedDbusCommand) command).handleCommand(ns, ts, dBusConn); - } else if (command instanceof DbusCommand) { - return ((DbusCommand) command).handleCommand(ns, ts); - } else { - System.err.println("Command is not yet implemented via dbus"); - return 1; - } - } - - private static int handleCommand(Command command, Namespace ns, ProvisioningManager pm) { - if (command instanceof ProvisioningCommand) { - return ((ProvisioningCommand) command).handleCommand(ns, pm); - } else { - System.err.println("Command only works with a username"); - return 1; - } - } - - private static int handleCommand(Command command, Namespace ns, RegistrationManager m) { - if (command instanceof RegistrationCommand) { - return ((RegistrationCommand) command).handleCommand(ns, m); - } - return 1; - } - - private static int handleCommand(Command command, Namespace ns, Manager m) { - if (command instanceof LocalCommand) { - return ((LocalCommand) command).handleCommand(ns, m); - } else if (command instanceof DbusCommand) { - return ((DbusCommand) command).handleCommand(ns, new DbusSignalImpl(m)); - } else { - System.err.println("Command only works via dbus"); - return 1; - } - } - - /** - * Uses $XDG_DATA_HOME/signal-cli if it exists, or if none of the legacy directories exist: - * - $HOME/.config/signal - * - $HOME/.config/textsecure - * - * @return the data directory to be used by signal-cli. - */ - private static File getDefaultDataPath() { - File dataPath = new File(IOUtils.getDataHomeDir(), "signal-cli"); - if (dataPath.exists()) { - return dataPath; - } - - File configPath = new File(System.getProperty("user.home"), ".config"); - - File legacySettingsPath = new File(configPath, "signal"); - if (legacySettingsPath.exists()) { - return legacySettingsPath; - } - - legacySettingsPath = new File(configPath, "textsecure"); - if (legacySettingsPath.exists()) { - return legacySettingsPath; - } - - return dataPath; + return ns.getBoolean("verbose"); } private static Namespace parseArgs(String[] args) { @@ -276,30 +111,17 @@ public class Main { System.exit(2); } } + if (ns.getList("recipient") != null && !ns.getList("recipient").isEmpty() && ns.getString("group") != null) { System.err.println("You cannot specify recipients by phone number and groups at the same time"); System.exit(2); } + return ns; } private static ArgumentParser buildArgumentParser() { - ArgumentParser parser = ArgumentParsers.newFor("signal-cli") - .build() - .defaultHelp(true) - .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("--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()); + ArgumentParser parser = buildBaseArgumentParser(); Subparsers subparsers = parser.addSubparsers() .title("subcommands") @@ -312,6 +134,36 @@ public class Main { Subparser subparser = subparsers.addParser(entry.getKey()); entry.getValue().attachToSubparser(subparser); } + + return parser; + } + + private static ArgumentParser buildBaseArgumentParser() { + ArgumentParser parser = ArgumentParsers.newFor("signal-cli") + .build() + .defaultHelp(true) + .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("--verbose") + .help("Raise log level and include lib signal logs.") + .action(Arguments.storeTrue()); + 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()); + + parser.addArgument("-o", "--output") + .help("Choose to output in plain text or JSON") + .choices("plain-text", "json") + .setDefault("plain-text"); + return parser; } } diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 1e081dff..4bc17930 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -42,6 +42,13 @@ public class Commands { return commands; } + public static Command getCommand(String commandKey) { + if (!commands.containsKey(commandKey)) { + return null; + } + return commands.get(commandKey); + } + private static void addCommand(String name, Command command) { commands.put(name, command); } diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index f6e13450..d49a0b99 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -10,6 +10,8 @@ import org.asamk.signal.dbus.DbusSignalImpl; import org.asamk.signal.manager.Manager; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.concurrent.TimeUnit; @@ -20,6 +22,8 @@ import static org.asamk.signal.util.ErrorUtils.handleAssertionError; public class DaemonCommand implements LocalCommand { + private final static Logger logger = LoggerFactory.getLogger(ReceiveCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("--system") @@ -29,12 +33,19 @@ public class DaemonCommand implements LocalCommand { .help("Don’t download attachments of received messages.") .action(Arguments.storeTrue()); subparser.addArgument("--json") - .help("Output received messages in json format, one json object per line.") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") .action(Arguments.storeTrue()); } @Override public int handleCommand(final Namespace ns, final Manager m) { + boolean inJson = ns.getString("output").equals("json") || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + DBusConnection conn = null; try { try { @@ -66,7 +77,7 @@ public class DaemonCommand implements LocalCommand { TimeUnit.HOURS, false, ignoreAttachments, - ns.getBoolean("json") + inJson ? new JsonDbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH) : new DbusReceiveMessageHandler(m, conn, SIGNAL_OBJECTPATH)); return 0; diff --git a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java index 0a1ddc4c..8b694cb6 100644 --- a/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java +++ b/src/main/java/org/asamk/signal/commands/GetUserStatusCommand.java @@ -8,6 +8,8 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.manager.Manager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.HashSet; @@ -17,12 +19,15 @@ import java.util.stream.Collectors; public class GetUserStatusCommand implements LocalCommand { + // TODO delete later when "json" variable is removed + private final static Logger logger = LoggerFactory.getLogger(GetUserStatusCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("number").help("Phone number").nargs("+"); subparser.help("Check if the specified phone number/s have been registered"); subparser.addArgument("--json") - .help("Output received messages in json format, one json object per line.") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") .action(Arguments.storeTrue()); } @@ -32,6 +37,13 @@ public class GetUserStatusCommand implements LocalCommand { ObjectMapper jsonProcessor = new ObjectMapper(); jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + boolean inJson = ns.getString("output").equals("json") || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + // Get a map of registration statuses Map registered; try { @@ -42,7 +54,7 @@ public class GetUserStatusCommand implements LocalCommand { } // Output - if (ns.getBoolean("json")) { + if (inJson) { List objects = registered.entrySet() .stream() .map(entry -> new JsonIsRegistered(entry.getKey(), entry.getValue())) diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index 97af502e..debc2a42 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -9,32 +9,39 @@ import org.asamk.signal.manager.groups.GroupInviteLinkUrl; import org.asamk.signal.manager.storage.groups.GroupInfo; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; public class ListGroupsCommand implements LocalCommand { - private static void printGroup(Manager m, GroupInfo group, boolean detailed) { + private static Set resolveMembers(Manager m, Set addresses) { + return addresses.stream().map(m::resolveSignalServiceAddress) + .map(SignalServiceAddress::getLegacyIdentifier) + .collect(Collectors.toSet()); + } + + private static int printGroupsJson(ObjectMapper jsonProcessor, List objects) { + try { + jsonProcessor.writeValue(System.out, objects); + System.out.println(); + } catch (IOException e) { + System.err.println(e.getMessage()); + return 1; + } + + return 0; + } + + private static void printGroupPlainText(Manager m, GroupInfo group, boolean detailed) { if (detailed) { - Set members = group.getMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); - - Set pendingMembers = group.getPendingMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); - - Set requestingMembers = group.getRequestingMembers() - .stream() - .map(m::resolveSignalServiceAddress) - .map(SignalServiceAddress::getLegacyIdentifier) - .collect(Collectors.toSet()); - final GroupInviteLinkUrl groupInviteLink = group.getGroupInviteLink(); System.out.println(String.format( @@ -43,9 +50,9 @@ public class ListGroupsCommand implements LocalCommand { group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked(), - members, - pendingMembers, - requestingMembers, + resolveMembers(m, group.getMembers()), + resolveMembers(m, group.getPendingMembers()), + resolveMembers(m, group.getRequestingMembers()), groupInviteLink == null ? '-' : groupInviteLink.getUrl())); } else { System.out.println(String.format("Id: %s Name: %s Active: %s Blocked: %b", @@ -58,18 +65,68 @@ public class ListGroupsCommand implements LocalCommand { @Override public void attachToSubparser(final Subparser subparser) { - subparser.addArgument("-d", "--detailed").action(Arguments.storeTrue()).help("List members of each group"); - subparser.help("List group name and ids"); + subparser.addArgument("-d", "--detailed").action(Arguments.storeTrue()) + .help("List the members and group invite links of each group. If output=json, then this is always set"); + + subparser.help("List group information including names, ids, active status, blocked status and members"); } @Override public int handleCommand(final Namespace ns, final Manager m) { - List groups = m.getGroups(); - boolean detailed = ns.getBoolean("detailed"); + if (ns.getString("output").equals("json")) { + final ObjectMapper jsonProcessor = new ObjectMapper(); + jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - for (GroupInfo group : groups) { - printGroup(m, group, detailed); + List objects = new ArrayList<>(); + for (GroupInfo group : m.getGroups()) { + final GroupInviteLinkUrl groupInviteLink = group.getGroupInviteLink(); + + objects.add(new JsonGroup(group.getGroupId().toBase64(), + group.getTitle(), + group.isMember(m.getSelfAddress()), + group.isBlocked(), + resolveMembers(m, group.getMembers()), + resolveMembers(m, group.getPendingMembers()), + resolveMembers(m, group.getRequestingMembers()), + groupInviteLink == null ? null : groupInviteLink.getUrl())); + } + return printGroupsJson(jsonProcessor, objects); + } else { + boolean detailed = ns.getBoolean("detailed"); + for (GroupInfo group : m.getGroups()) { + printGroupPlainText(m, group, detailed); + } } + return 0; } + + private static final class JsonGroup { + + public String id; + public String name; + public boolean isMember; + public boolean isBlocked; + + public Set members; + public Set pendingMembers; + public Set requestingMembers; + public String groupInviteLink; + + public JsonGroup(String id, String name, boolean isMember, boolean isBlocked, + Set members, Set pendingMembers, + Set requestingMembers, String groupInviteLink) + { + this.id = id; + this.name = name; + this.isMember = isMember; + this.isBlocked = isBlocked; + + this.members = members; + this.pendingMembers = pendingMembers; + this.requestingMembers = requestingMembers; + this.groupInviteLink = groupInviteLink; + } + } } diff --git a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java index 7dc9dcaf..9d718abb 100644 --- a/src/main/java/org/asamk/signal/commands/ReceiveCommand.java +++ b/src/main/java/org/asamk/signal/commands/ReceiveCommand.java @@ -18,6 +18,8 @@ import org.asamk.signal.manager.Manager; import org.asamk.signal.util.DateUtils; import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.exceptions.DBusException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.util.Base64; import java.io.IOException; @@ -27,6 +29,9 @@ import static org.asamk.signal.util.ErrorUtils.handleAssertionError; public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { + // TODO delete later when "json" variable is removed + private final static Logger logger = LoggerFactory.getLogger(ReceiveCommand.class); + @Override public void attachToSubparser(final Subparser subparser) { subparser.addArgument("-t", "--timeout") @@ -36,13 +41,21 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { .help("Don’t download attachments of received messages.") .action(Arguments.storeTrue()); subparser.addArgument("--json") - .help("Output received messages in json format, one json object per line.") + .help("WARNING: This parameter is now deprecated! Please use the global \"--output=json\" option instead.\n\nOutput received messages in json format, one json object per line.") .action(Arguments.storeTrue()); } public int handleCommand(final Namespace ns, final Signal signal, DBusConnection dbusconnection) { final ObjectMapper jsonProcessor; + + boolean inJson = ns.getString("output").equals("json") || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + + if (inJson) { jsonProcessor = new ObjectMapper(); jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); @@ -146,6 +159,13 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { @Override public int handleCommand(final Namespace ns, final Manager m) { + boolean inJson = ns.getString("output").equals("json") || ns.getBoolean("json"); + + // TODO delete later when "json" variable is removed + if (ns.getBoolean("json")) { + logger.warn("\"--json\" option has been deprecated, please use the global \"--output=json\" instead."); + } + double timeout = 5; if (ns.getDouble("timeout") != null) { timeout = ns.getDouble("timeout"); @@ -157,7 +177,7 @@ public class ReceiveCommand implements ExtendedDbusCommand, LocalCommand { } boolean ignoreAttachments = ns.getBoolean("ignore_attachments"); try { - final Manager.ReceiveMessageHandler handler = ns.getBoolean("json") + final Manager.ReceiveMessageHandler handler = inJson ? new JsonReceiveMessageHandler(m) : new ReceiveMessageHandler(m); m.receiveMessages((long) (timeout * 1000), diff --git a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java index 968a8733..c2ff2e5e 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateProfileCommand.java @@ -6,6 +6,7 @@ import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.manager.Manager; +import org.whispersystems.libsignal.util.guava.Optional; import java.io.File; import java.io.IOException; @@ -14,7 +15,7 @@ public class UpdateProfileCommand implements LocalCommand { @Override public void attachToSubparser(final Subparser subparser) { - final MutuallyExclusiveGroup avatarOptions = subparser.addMutuallyExclusiveGroup().required(true); + final MutuallyExclusiveGroup avatarOptions = subparser.addMutuallyExclusiveGroup(); avatarOptions.addArgument("--avatar").help("Path to new profile avatar"); avatarOptions.addArgument("--remove-avatar").action(Arguments.storeTrue()); @@ -30,7 +31,9 @@ public class UpdateProfileCommand implements LocalCommand { boolean removeAvatar = ns.getBoolean("remove_avatar"); try { - File avatarFile = removeAvatar ? null : new File(avatarPath); + Optional avatarFile = removeAvatar + ? Optional.absent() + : avatarPath == null ? null : Optional.of(new File(avatarPath)); m.setProfile(name, avatarFile); } catch (IOException e) { System.err.println("UpdateAccount error: " + e.getMessage()); diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 278fbbd4..69747b65 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -14,6 +14,7 @@ import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -193,7 +194,7 @@ public class DbusSignalImpl implements Signal { } final Pair> results = m.updateGroup(groupId == null ? null - : GroupId.unknownVersion(groupId), name, members, avatar); + : GroupId.unknownVersion(groupId), name, members, avatar == null ? null : new File(avatar)); checkSendMessageResults(0, results.second()); return results.first().serialize(); } catch (IOException e) { diff --git a/src/main/java/org/asamk/signal/json/JsonDataMessage.java b/src/main/java/org/asamk/signal/json/JsonDataMessage.java index 57201eda..787f47ab 100644 --- a/src/main/java/org/asamk/signal/json/JsonDataMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonDataMessage.java @@ -19,6 +19,7 @@ class JsonDataMessage { JsonQuote quote; List mentions; List attachments; + JsonSticker sticker; JsonGroupInfo groupInfo; JsonDataMessage(SignalServiceDataMessage dataMessage, Manager m) { @@ -60,15 +61,19 @@ class JsonDataMessage { } else { this.attachments = List.of(); } + if (dataMessage.getSticker().isPresent()) { + this.sticker = new JsonSticker(dataMessage.getSticker().get()); + } } public JsonDataMessage(Signal.MessageReceived messageReceived) { timestamp = messageReceived.getTimestamp(); message = messageReceived.getMessage(); groupInfo = new JsonGroupInfo(messageReceived.getGroupId()); - reaction = null; // TODO Replace these 3 with the proper commands + reaction = null; // TODO Replace these 4 with the proper commands quote = null; mentions = null; + sticker = null; attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList()); } @@ -76,9 +81,10 @@ class JsonDataMessage { timestamp = messageReceived.getTimestamp(); message = messageReceived.getMessage(); groupInfo = new JsonGroupInfo(messageReceived.getGroupId()); - reaction = null; // TODO Replace these 3 with the proper commands + reaction = null; // TODO Replace these 4 with the proper commands quote = null; mentions = null; + sticker = null; attachments = messageReceived.getAttachments().stream().map(JsonAttachment::new).collect(Collectors.toList()); } } diff --git a/src/main/java/org/asamk/signal/json/JsonSticker.java b/src/main/java/org/asamk/signal/json/JsonSticker.java new file mode 100644 index 00000000..228d2883 --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonSticker.java @@ -0,0 +1,18 @@ +package org.asamk.signal.json; + +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.util.Base64; + +public class JsonSticker { + + String packId; + String packKey; + int stickerId; + + public JsonSticker(SignalServiceDataMessage.Sticker sticker) { + this.packId = Base64.encodeBytes(sticker.getPackId()); + this.packKey = Base64.encodeBytes(sticker.getPackKey()); + this.stickerId = sticker.getStickerId(); + // TODO also download sticker image ?? + } +} diff --git a/src/main/java/org/asamk/signal/manager/AttachmentStore.java b/src/main/java/org/asamk/signal/manager/AttachmentStore.java new file mode 100644 index 00000000..f983a90b --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/AttachmentStore.java @@ -0,0 +1,55 @@ +package org.asamk.signal.manager; + +import org.asamk.signal.manager.util.IOUtils; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public class AttachmentStore { + + private final File attachmentsPath; + + public AttachmentStore(final File attachmentsPath) { + this.attachmentsPath = attachmentsPath; + } + + public void storeAttachmentPreview( + final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer + ) throws IOException { + storeAttachment(getAttachmentPreviewFile(attachmentId), storer); + } + + public void storeAttachment( + final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer + ) throws IOException { + storeAttachment(getAttachmentFile(attachmentId), storer); + } + + private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException { + createAttachmentsDir(); + try (OutputStream output = new FileOutputStream(attachmentFile)) { + storer.store(output); + } + } + + private File getAttachmentPreviewFile(SignalServiceAttachmentRemoteId attachmentId) { + return new File(attachmentsPath, attachmentId.toString() + ".preview"); + } + + public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { + return new File(attachmentsPath, attachmentId.toString()); + } + + private void createAttachmentsDir() throws IOException { + IOUtils.createPrivateDirectories(attachmentsPath); + } + + @FunctionalInterface + public interface AttachmentStorer { + + void store(OutputStream outputStream) throws IOException; + } +} diff --git a/src/main/java/org/asamk/signal/manager/AvatarStore.java b/src/main/java/org/asamk/signal/manager/AvatarStore.java new file mode 100644 index 00000000..b7244ce2 --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/AvatarStore.java @@ -0,0 +1,91 @@ +package org.asamk.signal.manager; + +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.util.IOUtils; +import org.asamk.signal.manager.util.Utils; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.StreamDetails; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; + +public class AvatarStore { + + private final File avatarsPath; + + public AvatarStore(final File avatarsPath) { + this.avatarsPath = avatarsPath; + } + + public StreamDetails retrieveContactAvatar(SignalServiceAddress address) throws IOException { + return retrieveAvatar(getContactAvatarFile(address)); + } + + public StreamDetails retrieveProfileAvatar(SignalServiceAddress address) throws IOException { + return retrieveAvatar(getProfileAvatarFile(address)); + } + + public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException { + final File groupAvatarFile = getGroupAvatarFile(groupId); + return retrieveAvatar(groupAvatarFile); + } + + public void storeContactAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException { + storeAvatar(getContactAvatarFile(address), storer); + } + + public void storeProfileAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException { + storeAvatar(getProfileAvatarFile(address), storer); + } + + public void storeGroupAvatar(GroupId groupId, AvatarStorer storer) throws IOException { + storeAvatar(getGroupAvatarFile(groupId), storer); + } + + public void deleteProfileAvatar(SignalServiceAddress address) throws IOException { + deleteAvatar(getProfileAvatarFile(address)); + } + + private StreamDetails retrieveAvatar(final File avatarFile) throws IOException { + if (!avatarFile.exists()) { + return null; + } + return Utils.createStreamDetailsFromFile(avatarFile); + } + + private void storeAvatar(final File avatarFile, final AvatarStorer storer) throws IOException { + createAvatarsDir(); + try (OutputStream output = new FileOutputStream(avatarFile)) { + storer.store(output); + } + } + + private void deleteAvatar(final File avatarFile) throws IOException { + Files.delete(avatarFile.toPath()); + } + + private File getGroupAvatarFile(GroupId groupId) { + return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_")); + } + + private File getContactAvatarFile(SignalServiceAddress address) { + return new File(avatarsPath, "contact-" + address.getLegacyIdentifier()); + } + + private File getProfileAvatarFile(SignalServiceAddress address) { + return new File(avatarsPath, "profile-" + address.getLegacyIdentifier()); + } + + private void createAvatarsDir() throws IOException { + IOUtils.createPrivateDirectories(avatarsPath); + } + + @FunctionalInterface + public interface AvatarStorer { + + void store(OutputStream outputStream) throws IOException; + } +} diff --git a/src/main/java/org/asamk/signal/manager/HandleAction.java b/src/main/java/org/asamk/signal/manager/HandleAction.java index 0dd151a9..8338e4e6 100644 --- a/src/main/java/org/asamk/signal/manager/HandleAction.java +++ b/src/main/java/org/asamk/signal/manager/HandleAction.java @@ -124,19 +124,19 @@ class SendGroupInfoRequestAction implements HandleAction { } } -class SendGroupUpdateAction implements HandleAction { +class SendGroupInfoAction implements HandleAction { private final SignalServiceAddress address; private final GroupIdV1 groupId; - public SendGroupUpdateAction(final SignalServiceAddress address, final GroupIdV1 groupId) { + public SendGroupInfoAction(final SignalServiceAddress address, final GroupIdV1 groupId) { this.address = address; this.groupId = groupId; } @Override public void execute(Manager m) throws Throwable { - m.sendUpdateGroupMessage(groupId, address); + m.sendGroupInfoMessage(groupId, address); } @Override @@ -144,7 +144,7 @@ class SendGroupUpdateAction implements HandleAction { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - final SendGroupUpdateAction that = (SendGroupUpdateAction) o; + final SendGroupInfoAction that = (SendGroupInfoAction) o; if (!address.equals(that.address)) return false; return groupId.equals(that.groupId); diff --git a/src/main/java/org/asamk/signal/manager/JsonStickerPack.java b/src/main/java/org/asamk/signal/manager/JsonStickerPack.java index a7e5eb7f..e5e0e445 100644 --- a/src/main/java/org/asamk/signal/manager/JsonStickerPack.java +++ b/src/main/java/org/asamk/signal/manager/JsonStickerPack.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; -class JsonStickerPack { +public class JsonStickerPack { @JsonProperty public String title; diff --git a/src/main/java/org/asamk/signal/manager/LibSignalLogger.java b/src/main/java/org/asamk/signal/manager/LibSignalLogger.java new file mode 100644 index 00000000..9118846d --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/LibSignalLogger.java @@ -0,0 +1,41 @@ +package org.asamk.signal.manager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.libsignal.logging.SignalProtocolLogger; +import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider; + +public class LibSignalLogger implements SignalProtocolLogger { + + private final static Logger logger = LoggerFactory.getLogger("LibSignal"); + + public static void initLogger() { + SignalProtocolLoggerProvider.setProvider(new LibSignalLogger()); + } + + private LibSignalLogger() { + } + + @Override + public void log(final int priority, final String tag, final String message) { + final String logMessage = String.format("[%s]: %s", tag, message); + switch (priority) { + case SignalProtocolLogger.VERBOSE: + logger.trace(logMessage); + break; + case SignalProtocolLogger.DEBUG: + logger.debug(logMessage); + break; + case SignalProtocolLogger.INFO: + logger.info(logMessage); + break; + case SignalProtocolLogger.WARN: + logger.warn(logMessage); + break; + case SignalProtocolLogger.ERROR: + case SignalProtocolLogger.ASSERT: + logger.error(logMessage); + break; + } + } +} diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index d0a9e277..a0f6aa53 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -16,8 +16,6 @@ */ package org.asamk.signal.manager; -import com.fasterxml.jackson.databind.ObjectMapper; - import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupIdV1; import org.asamk.signal.manager.groups.GroupIdV2; @@ -42,6 +40,8 @@ import org.asamk.signal.manager.storage.stickers.Sticker; import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.KeyUtils; +import org.asamk.signal.manager.util.ProfileUtils; +import org.asamk.signal.manager.util.StickerUtils; import org.asamk.signal.manager.util.Utils; import org.signal.libsignal.metadata.InvalidMetadataMessageException; import org.signal.libsignal.metadata.InvalidMetadataVersionException; @@ -74,12 +74,9 @@ import org.whispersystems.libsignal.IdentityKeyPair; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.InvalidMessageException; import org.whispersystems.libsignal.InvalidVersionException; -import org.whispersystems.libsignal.ecc.Curve; -import org.whispersystems.libsignal.ecc.ECKeyPair; import org.whispersystems.libsignal.ecc.ECPublicKey; import org.whispersystems.libsignal.state.PreKeyRecord; import org.whispersystems.libsignal.state.SignedPreKeyRecord; -import org.whispersystems.libsignal.util.Medium; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.KeyBackupService; @@ -87,8 +84,6 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessagePipe; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; -import org.whispersystems.signalservice.api.crypto.ProfileCipher; import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -110,7 +105,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload; -import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload.StickerInfo; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; @@ -144,12 +138,10 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider; import org.whispersystems.signalservice.internal.util.Hex; -import org.whispersystems.util.Base64; import java.io.Closeable; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -159,8 +151,6 @@ import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.security.SignatureException; import java.util.ArrayList; import java.util.Arrays; @@ -176,8 +166,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import static org.asamk.signal.manager.ServiceConfig.CDS_MRENCLAVE; import static org.asamk.signal.manager.ServiceConfig.capabilities; @@ -185,7 +173,7 @@ import static org.asamk.signal.manager.ServiceConfig.getIasKeyStore; public class Manager implements Closeable { - final static Logger logger = LoggerFactory.getLogger(Manager.class); + private final static Logger logger = LoggerFactory.getLogger(Manager.class); private final CertificateValidator certificateValidator = new CertificateValidator(ServiceConfig.getUnidentifiedSenderTrustRoot()); @@ -193,7 +181,6 @@ public class Manager implements Closeable { private final String userAgent; private SignalAccount account; - private final PathConfig pathConfig; private final SignalServiceAccountManager accountManager; private final GroupsV2Api groupsV2Api; private final GroupsV2Operations groupsV2Operations; @@ -207,6 +194,8 @@ public class Manager implements Closeable { private final ProfileHelper profileHelper; private final GroupHelper groupHelper; private final PinHelper pinHelper; + private final AvatarStore avatarStore; + private final AttachmentStore attachmentStore; Manager( SignalAccount account, @@ -215,7 +204,6 @@ public class Manager implements Closeable { String userAgent ) { this.account = account; - this.pathConfig = pathConfig; this.serviceConfiguration = serviceConfiguration; this.userAgent = userAgent; this.groupsV2Operations = capabilities.isGv2() ? new GroupsV2Operations(ClientZkOperations.create( @@ -262,6 +250,8 @@ public class Manager implements Closeable { groupsV2Operations, groupsV2Api, this::getGroupAuthForToday); + this.avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); + this.attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); } public String getUsername() { @@ -299,21 +289,15 @@ public class Manager implements Closeable { } public void checkAccountState() throws IOException { - if (account.isRegistered()) { - if (accountManager.getPreKeysCount() < ServiceConfig.PREKEY_MINIMUM_COUNT) { - refreshPreKeys(); - account.save(); - } - if (account.getUuid() == null) { - account.setUuid(accountManager.getOwnUuid()); - account.save(); - } - updateAccountAttributes(); + if (accountManager.getPreKeysCount() < ServiceConfig.PREKEY_MINIMUM_COUNT) { + refreshPreKeys(); + account.save(); } - } - - public boolean isRegistered() { - return account.isRegistered(); + if (account.getUuid() == null) { + account.setUuid(accountManager.getOwnUuid()); + account.save(); + } + updateAccountAttributes(); } /** @@ -321,7 +305,7 @@ public class Manager implements Closeable { * * @param numbers The set of phone number in question * @return A map of numbers to booleans. True if registered, false otherwise. Should never be null - * @throws IOException if its unable to check if the users are registered + * @throws IOException if its unable to get the contacts to check if they're registered */ public Map areUsersRegistered(Set numbers) throws IOException { // Note "contactDetails" has no optionals. It only gives us info on users who are registered @@ -347,10 +331,25 @@ public class Manager implements Closeable { account.isDiscoverableByPhoneNumber()); } - public void setProfile(String name, File avatar) throws IOException { - try (final StreamDetails streamDetails = avatar == null ? null : Utils.createStreamDetailsFromFile(avatar)) { + /** + * @param avatar if avatar is null the image from the local avatar store is used (if present), + * if it's Optional.absent(), the avatar will be removed + */ + public void setProfile(String name, Optional avatar) throws IOException { + try (final StreamDetails streamDetails = avatar == null + ? avatarStore.retrieveProfileAvatar(getSelfAddress()) + : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) { accountManager.setVersionedProfile(account.getUuid(), account.getProfileKey(), name, streamDetails); } + + if (avatar != null) { + if (avatar.isPresent()) { + avatarStore.storeProfileAvatar(getSelfAddress(), + outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream)); + } else { + avatarStore.deleteProfileAvatar(getSelfAddress()); + } + } } public void unregister() throws IOException { @@ -396,43 +395,6 @@ public class Manager implements Closeable { account.save(); } - private List generatePreKeys() { - List records = new ArrayList<>(ServiceConfig.PREKEY_BATCH_SIZE); - - final int offset = account.getPreKeyIdOffset(); - for (int i = 0; i < ServiceConfig.PREKEY_BATCH_SIZE; i++) { - int preKeyId = (offset + i) % Medium.MAX_VALUE; - ECKeyPair keyPair = Curve.generateKeyPair(); - PreKeyRecord record = new PreKeyRecord(preKeyId, keyPair); - - records.add(record); - } - - account.addPreKeys(records); - account.save(); - - return records; - } - - private SignedPreKeyRecord generateSignedPreKey(IdentityKeyPair identityKeyPair) { - try { - ECKeyPair keyPair = Curve.generateKeyPair(); - byte[] signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), - keyPair.getPublicKey().serialize()); - SignedPreKeyRecord record = new SignedPreKeyRecord(account.getNextSignedPreKeyId(), - System.currentTimeMillis(), - keyPair, - signature); - - account.addSignedPreKey(record); - account.save(); - - return record; - } catch (InvalidKeyException e) { - throw new AssertionError(e); - } - } - public void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException { if (pin.isPresent()) { final MasterKey masterKey = account.getPinMasterKey() != null @@ -464,6 +426,26 @@ public class Manager implements Closeable { accountManager.setPreKeys(identityKeyPair.getPublicKey(), signedPreKeyRecord, oneTimePreKeys); } + private List generatePreKeys() { + final int offset = account.getPreKeyIdOffset(); + + List records = KeyUtils.generatePreKeyRecords(offset, ServiceConfig.PREKEY_BATCH_SIZE); + account.addPreKeys(records); + account.save(); + + return records; + } + + private SignedPreKeyRecord generateSignedPreKey(IdentityKeyPair identityKeyPair) { + final int signedPreKeyId = account.getNextSignedPreKeyId(); + + SignedPreKeyRecord record = KeyUtils.generateSignedPreKeyRecord(identityKeyPair, signedPreKeyId); + account.addSignedPreKey(record); + account.save(); + + return record; + } + private SignalServiceMessagePipe getOrCreateMessagePipe() { if (messagePipe == null) { messagePipe = messageReceiver.createMessagePipe(); @@ -496,10 +478,6 @@ public class Manager implements Closeable { ServiceConfig.MAX_ENVELOPE_SIZE); } - private SignalServiceProfile getEncryptedRecipientProfile(SignalServiceAddress address) throws IOException { - return profileHelper.retrieveProfileSync(address, SignalServiceProfile.RequestType.PROFILE).getProfile(); - } - private SignalProfile getRecipientProfile( SignalServiceAddress address ) { @@ -508,21 +486,24 @@ public class Manager implements Closeable { return null; } long now = new Date().getTime(); - // Profiles are cache for 24h before retrieving them again + // Profiles are cached for 24h before retrieving them again if (!profileEntry.isRequestPending() && ( profileEntry.getProfile() == null || now - profileEntry.getLastUpdateTimestamp() > 24 * 60 * 60 * 1000 )) { - ProfileKey profileKey = profileEntry.getProfileKey(); profileEntry.setRequestPending(true); - SignalProfile profile; + final SignalServiceProfile encryptedProfile; try { - profile = retrieveRecipientProfile(address, profileKey); + encryptedProfile = profileHelper.retrieveProfileSync(address, SignalServiceProfile.RequestType.PROFILE) + .getProfile(); } catch (IOException e) { logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage()); - profileEntry.setRequestPending(false); return null; + } finally { + profileEntry.setRequestPending(false); } - profileEntry.setRequestPending(false); + + final ProfileKey profileKey = profileEntry.getProfileKey(); + final SignalProfile profile = decryptProfileAndDownloadAvatar(address, profileKey, encryptedProfile); account.getProfileStore() .updateProfile(address, profileKey, now, profile, profileEntry.getProfileKeyCredential()); return profile; @@ -547,7 +528,7 @@ public class Manager implements Closeable { long now = new Date().getTime(); final ProfileKeyCredential profileKeyCredential = profileAndCredential.getProfileKeyCredential().orNull(); - final SignalProfile profile = decryptProfile(address, + final SignalProfile profile = decryptProfileAndDownloadAvatar(address, profileEntry.getProfileKey(), profileAndCredential.getProfile()); account.getProfileStore() @@ -557,72 +538,32 @@ public class Manager implements Closeable { return profileEntry.getProfileKeyCredential(); } - private SignalProfile retrieveRecipientProfile( - SignalServiceAddress address, ProfileKey profileKey - ) throws IOException { - final SignalServiceProfile encryptedProfile = getEncryptedRecipientProfile(address); - - return decryptProfile(address, profileKey, encryptedProfile); - } - - private SignalProfile decryptProfile( + private SignalProfile decryptProfileAndDownloadAvatar( final SignalServiceAddress address, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile ) { - File avatarFile = null; - try { - avatarFile = encryptedProfile.getAvatar() == null - ? null - : retrieveProfileAvatar(address, encryptedProfile.getAvatar(), profileKey); - } catch (Throwable e) { - logger.warn("Failed to retrieve profile avatar, ignoring: {}", e.getMessage()); + if (encryptedProfile.getAvatar() != null) { + downloadProfileAvatar(address, encryptedProfile.getAvatar(), profileKey); } - ProfileCipher profileCipher = new ProfileCipher(profileKey); - try { - String name; - try { - name = encryptedProfile.getName() == null - ? null - : new String(profileCipher.decryptName(Base64.decode(encryptedProfile.getName()))); - } catch (IOException e) { - name = null; - } - String unidentifiedAccess; - try { - unidentifiedAccess = encryptedProfile.getUnidentifiedAccess() == null - || !profileCipher.verifyUnidentifiedAccess(Base64.decode(encryptedProfile.getUnidentifiedAccess())) - ? null - : encryptedProfile.getUnidentifiedAccess(); - } catch (IOException e) { - unidentifiedAccess = null; - } - return new SignalProfile(encryptedProfile.getIdentityKey(), - name, - avatarFile, - unidentifiedAccess, - encryptedProfile.isUnrestrictedUnidentifiedAccess(), - encryptedProfile.getCapabilities()); - } catch (InvalidCiphertextException e) { - return null; - } + return ProfileUtils.decryptProfile(profileKey, encryptedProfile); } private Optional createGroupAvatarAttachment(GroupId groupId) throws IOException { - File file = getGroupAvatarFile(groupId); - if (!file.exists()) { + final StreamDetails streamDetails = avatarStore.retrieveGroupAvatar(groupId); + if (streamDetails == null) { return Optional.absent(); } - return Optional.of(AttachmentUtils.createAttachment(file)); + return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); } - private Optional createContactAvatarAttachment(String number) throws IOException { - File file = getContactAvatarFile(number); - if (!file.exists()) { + private Optional createContactAvatarAttachment(SignalServiceAddress address) throws IOException { + final StreamDetails streamDetails = avatarStore.retrieveContactAvatar(address); + if (streamDetails == null) { return Optional.absent(); } - return Optional.of(AttachmentUtils.createAttachment(file)); + return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); } private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { @@ -651,17 +592,6 @@ public class Manager implements Closeable { return account.getGroupStore().getGroups(); } - public Pair> sendGroupMessage( - SignalServiceDataMessage.Builder messageBuilder, GroupId groupId - ) throws IOException, GroupNotFoundException, NotAGroupMemberException { - final GroupInfo g = getGroupForSending(groupId); - - GroupUtils.setGroupContext(messageBuilder, g); - messageBuilder.withExpiration(g.getMessageExpirationTime()); - - return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); - } - public Pair> sendGroupMessage( String messageText, List attachments, GroupId groupId ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { @@ -687,8 +617,18 @@ public class Manager implements Closeable { return sendGroupMessage(messageBuilder, groupId); } - public Pair> sendQuitGroupMessage(GroupId groupId) throws GroupNotFoundException, IOException, NotAGroupMemberException { + public Pair> sendGroupMessage( + SignalServiceDataMessage.Builder messageBuilder, GroupId groupId + ) throws IOException, GroupNotFoundException, NotAGroupMemberException { + final GroupInfo g = getGroupForSending(groupId); + GroupUtils.setGroupContext(messageBuilder, g); + messageBuilder.withExpiration(g.getMessageExpirationTime()); + + return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); + } + + public Pair> sendQuitGroupMessage(GroupId groupId) throws GroupNotFoundException, IOException, NotAGroupMemberException { SignalServiceDataMessage.Builder messageBuilder; final GroupInfo g = getGroupForUpdating(groupId); @@ -711,14 +651,25 @@ public class Manager implements Closeable { return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); } + public Pair> updateGroup( + GroupId groupId, String name, List members, File avatarFile + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, InvalidNumberException, NotAGroupMemberException { + return sendUpdateGroupMessage(groupId, + name, + members == null ? null : getSignalServiceAddresses(members), + avatarFile); + } + private Pair> sendUpdateGroupMessage( - GroupId groupId, String name, Collection members, String avatarFile + GroupId groupId, String name, Collection members, File avatarFile ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { GroupInfo g; SignalServiceDataMessage.Builder messageBuilder; if (groupId == null) { // Create new group - GroupInfoV2 gv2 = groupHelper.createGroupV2(name, members, avatarFile); + GroupInfoV2 gv2 = groupHelper.createGroupV2(name == null ? "" : name, + members == null ? List.of() : members, + avatarFile); if (gv2 == null) { GroupInfoV1 gv1 = new GroupInfoV1(GroupIdV1.createRandom()); gv1.addMembers(List.of(account.getSelfAddress())); @@ -726,6 +677,10 @@ public class Manager implements Closeable { messageBuilder = getGroupUpdateMessageBuilder(gv1); g = gv1; } else { + if (avatarFile != null) { + avatarStore.storeGroupAvatar(gv2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } messageBuilder = getGroupUpdateMessageBuilder(gv2, null); g = gv2; } @@ -760,6 +715,10 @@ public class Manager implements Closeable { Pair groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2, name, avatarFile); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(groupInfoV2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } result = sendUpdateGroupMessage(groupInfoV2, groupGroupChangePair.first(), groupGroupChangePair.second()); @@ -781,6 +740,45 @@ public class Manager implements Closeable { return new Pair<>(g.getGroupId(), result.second()); } + private void updateGroupV1( + final GroupInfoV1 g, + final String name, + final Collection members, + final File avatarFile + ) throws IOException { + if (name != null) { + g.name = name; + } + + if (members != null) { + final Set newE164Members = new HashSet<>(); + for (SignalServiceAddress member : members) { + if (g.isMember(member) || !member.getNumber().isPresent()) { + continue; + } + newE164Members.add(member.getNumber().get()); + } + + final List contacts = accountManager.getContacts(newE164Members); + if (contacts.size() != newE164Members.size()) { + // Some of the new members are not registered on Signal + for (ContactTokenDetails contact : contacts) { + newE164Members.remove(contact.getNumber()); + } + throw new IOException("Failed to add members " + + String.join(", ", newE164Members) + + " to group: Not registered on Signal"); + } + + g.addMembers(members); + } + + if (avatarFile != null) { + avatarStore.storeGroupAvatar(g.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + } + public Pair> joinGroup( GroupInviteLinkUrl inviteLinkUrl ) throws IOException, GroupLinkNotActiveException { @@ -809,6 +807,28 @@ public class Manager implements Closeable { return new Pair<>(group.getGroupId(), result.second()); } + private static int currentTimeDays() { + return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); + } + + private GroupsV2AuthorizationString getGroupAuthForToday( + final GroupSecretParams groupSecretParams + ) throws IOException { + final int today = currentTimeDays(); + // Returns credentials for the next 7 days + final HashMap credentials = groupsV2Api.getCredentials(today); + // TODO cache credentials until they expire + AuthCredentialResponse authCredentialResponse = credentials.get(today); + try { + return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), + today, + groupSecretParams, + authCredentialResponse); + } catch (VerificationFailedException e) { + throw new IOException(e); + } + } + private Pair> sendUpdateGroupMessage( GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange ) throws IOException { @@ -819,47 +839,7 @@ public class Manager implements Closeable { return sendMessage(messageBuilder, group.getMembersIncludingPendingWithout(account.getSelfAddress())); } - private void updateGroupV1( - final GroupInfoV1 g, - final String name, - final Collection members, - final String avatarFile - ) throws IOException { - if (name != null) { - g.name = name; - } - - if (members != null) { - final Set newE164Members = new HashSet<>(); - for (SignalServiceAddress member : members) { - if (g.isMember(member) || !member.getNumber().isPresent()) { - continue; - } - newE164Members.add(member.getNumber().get()); - } - - final List contacts = accountManager.getContacts(newE164Members); - if (contacts.size() != newE164Members.size()) { - // Some of the new members are not registered on Signal - for (ContactTokenDetails contact : contacts) { - newE164Members.remove(contact.getNumber()); - } - throw new IOException("Failed to add members " - + String.join(", ", newE164Members) - + " to group: Not registered on Signal"); - } - - g.addMembers(members); - } - - if (avatarFile != null) { - IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath()); - File aFile = getGroupAvatarFile(g.getGroupId()); - Files.copy(Paths.get(avatarFile), aFile.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - } - - Pair> sendUpdateGroupMessage( + Pair> sendGroupInfoMessage( GroupIdV1 groupId, SignalServiceAddress recipient ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException { GroupInfoV1 g; @@ -885,13 +865,13 @@ public class Manager implements Closeable { .withName(g.name) .withMembers(new ArrayList<>(g.getMembers())); - File aFile = getGroupAvatarFile(g.getGroupId()); - if (aFile.exists()) { - try { - group.withAvatar(AttachmentUtils.createAttachment(aFile)); - } catch (IOException e) { - throw new AttachmentInvalidException(aFile.toString(), e); + try { + final Optional attachment = createGroupAvatarAttachment(g.getGroupId()); + if (attachment.isPresent()) { + group.withAvatar(attachment.get()); } + } catch (IOException e) { + throw new AttachmentInvalidException(g.getGroupId().toBase64(), e); } return SignalServiceDataMessage.newBuilder() @@ -1029,15 +1009,6 @@ public class Manager implements Closeable { account.save(); } - public Pair> updateGroup( - GroupId groupId, String name, List members, String avatar - ) throws IOException, GroupNotFoundException, AttachmentInvalidException, InvalidNumberException, NotAGroupMemberException { - return sendUpdateGroupMessage(groupId, - name, - members == null ? null : getSignalServiceAddresses(members), - avatar); - } - /** * Change the expiration timer for a contact */ @@ -1086,7 +1057,7 @@ public class Manager implements Closeable { * @return if successful, returns the URL to install the sticker pack in the signal app */ public String uploadStickerPack(File path) throws IOException, StickerPackInvalidException { - SignalServiceStickerManifestUpload manifest = getSignalServiceStickerManifestUpload(path); + SignalServiceStickerManifestUpload manifest = StickerUtils.getSignalServiceStickerManifestUpload(path); SignalServiceMessageSender messageSender = createMessageSender(); @@ -1109,96 +1080,6 @@ public class Manager implements Closeable { } } - private SignalServiceStickerManifestUpload getSignalServiceStickerManifestUpload( - final File file - ) throws IOException, StickerPackInvalidException { - ZipFile zip = null; - String rootPath = null; - - if (file.getName().endsWith(".zip")) { - zip = new ZipFile(file); - } else if (file.getName().equals("manifest.json")) { - rootPath = file.getParent(); - } else { - throw new StickerPackInvalidException("Could not find manifest.json"); - } - - JsonStickerPack pack = parseStickerPack(rootPath, zip); - - if (pack.stickers == null) { - throw new StickerPackInvalidException("Must set a 'stickers' field."); - } - - if (pack.stickers.isEmpty()) { - throw new StickerPackInvalidException("Must include stickers."); - } - - List stickers = new ArrayList<>(pack.stickers.size()); - for (JsonStickerPack.JsonSticker sticker : pack.stickers) { - if (sticker.file == null) { - throw new StickerPackInvalidException("Must set a 'file' field on each sticker."); - } - - Pair data; - try { - data = getInputStreamAndLength(rootPath, zip, sticker.file); - } catch (IOException ignored) { - throw new StickerPackInvalidException("Could not find find " + sticker.file); - } - - String contentType = Utils.getFileMimeType(new File(sticker.file), null); - StickerInfo stickerInfo = new StickerInfo(data.first(), - data.second(), - Optional.fromNullable(sticker.emoji).or(""), - contentType); - stickers.add(stickerInfo); - } - - StickerInfo cover = null; - if (pack.cover != null) { - if (pack.cover.file == null) { - throw new StickerPackInvalidException("Must set a 'file' field on the cover."); - } - - Pair data; - try { - data = getInputStreamAndLength(rootPath, zip, pack.cover.file); - } catch (IOException ignored) { - throw new StickerPackInvalidException("Could not find find " + pack.cover.file); - } - - String contentType = Utils.getFileMimeType(new File(pack.cover.file), null); - cover = new StickerInfo(data.first(), - data.second(), - Optional.fromNullable(pack.cover.emoji).or(""), - contentType); - } - - return new SignalServiceStickerManifestUpload(pack.title, pack.author, cover, stickers); - } - - private static JsonStickerPack parseStickerPack(String rootPath, ZipFile zip) throws IOException { - InputStream inputStream; - if (zip != null) { - inputStream = zip.getInputStream(zip.getEntry("manifest.json")); - } else { - inputStream = new FileInputStream((new File(rootPath, "manifest.json"))); - } - return new ObjectMapper().readValue(inputStream, JsonStickerPack.class); - } - - private static Pair getInputStreamAndLength( - final String rootPath, final ZipFile zip, final String subfile - ) throws IOException { - if (zip != null) { - final ZipEntry entry = zip.getEntry(subfile); - return new Pair<>(zip.getInputStream(entry), entry.getSize()); - } else { - final File file = new File(rootPath, subfile); - return new Pair<>(new FileInputStream(file), file.length()); - } - } - void requestSyncGroups() throws IOException { SignalServiceProtos.SyncMessage.Request r = SignalServiceProtos.SyncMessage.Request.newBuilder() .setType(SignalServiceProtos.SyncMessage.Request.Type.GROUPS) @@ -1207,7 +1088,7 @@ public class Manager implements Closeable { try { sendSyncMessage(message); } catch (UntrustedIdentityException e) { - e.printStackTrace(); + throw new AssertionError(e); } } @@ -1219,7 +1100,7 @@ public class Manager implements Closeable { try { sendSyncMessage(message); } catch (UntrustedIdentityException e) { - e.printStackTrace(); + throw new AssertionError(e); } } @@ -1231,7 +1112,7 @@ public class Manager implements Closeable { try { sendSyncMessage(message); } catch (UntrustedIdentityException e) { - e.printStackTrace(); + throw new AssertionError(e); } } @@ -1243,7 +1124,7 @@ public class Manager implements Closeable { try { sendSyncMessage(message); } catch (UntrustedIdentityException e) { - e.printStackTrace(); + throw new AssertionError(e); } } @@ -1349,16 +1230,12 @@ public class Manager implements Closeable { } } else { // Send to all individually, so sync messages are sent correctly + messageBuilder.withProfileKey(account.getProfileKey().serialize()); List results = new ArrayList<>(recipients.size()); for (SignalServiceAddress address : recipients) { - ContactInfo contact = account.getContactStore().getContact(address); - if (contact != null) { - messageBuilder.withExpiration(contact.messageExpirationTime); - messageBuilder.withProfileKey(account.getProfileKey().serialize()); - } else { - messageBuilder.withExpiration(0); - messageBuilder.withProfileKey(null); - } + final ContactInfo contact = account.getContactStore().getContact(address); + final int expirationTime = contact != null ? contact.messageExpirationTime : 0; + messageBuilder.withExpiration(expirationTime); message = messageBuilder.build(); if (address.matches(account.getSelfAddress())) { results.add(sendSelfMessage(message)); @@ -1448,28 +1325,6 @@ public class Manager implements Closeable { account.getSignalProtocolStore().deleteAllSessions(source); } - private static int currentTimeDays() { - return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); - } - - private GroupsV2AuthorizationString getGroupAuthForToday( - final GroupSecretParams groupSecretParams - ) throws IOException { - final int today = currentTimeDays(); - // Returns credentials for the next 7 days - final HashMap credentials = groupsV2Api.getCredentials(today); - // TODO cache credentials until they expire - AuthCredentialResponse authCredentialResponse = credentials.get(today); - try { - return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), - today, - groupSecretParams, - authCredentialResponse); - } catch (VerificationFailedException e) { - throw new IOException(e); - } - } - private List handleSignalServiceDataMessage( SignalServiceDataMessage message, boolean isSync, @@ -1493,15 +1348,7 @@ public class Manager implements Closeable { if (groupInfo.getAvatar().isPresent()) { SignalServiceAttachment avatar = groupInfo.getAvatar().get(); - if (avatar.isPointer()) { - try { - retrieveGroupAvatarAttachment(avatar.asPointer(), groupV1.getGroupId()); - } catch (IOException | InvalidMessageException | MissingConfigurationException e) { - logger.warn("Failed to retrieve avatar for group {}, ignoring: {}", - groupId.toBase64(), - e.getMessage()); - } - } + downloadGroupAvatar(avatar, groupV1.getGroupId()); } if (groupInfo.getName().isPresent()) { @@ -1533,7 +1380,7 @@ public class Manager implements Closeable { } case REQUEST_INFO: if (groupV1 != null && !isSync) { - actions.add(new SendGroupUpdateAction(source, groupV1.getGroupId())); + actions.add(new SendGroupInfoAction(source, groupV1.getGroupId())); } break; } @@ -1582,15 +1429,7 @@ public class Manager implements Closeable { } if (message.getAttachments().isPresent() && !ignoreAttachments) { for (SignalServiceAttachment attachment : message.getAttachments().get()) { - if (attachment.isPointer()) { - try { - retrieveAttachment(attachment.asPointer()); - } catch (IOException | InvalidMessageException | MissingConfigurationException e) { - logger.warn("Failed to retrieve attachment ({}), ignoring: {}", - attachment.asPointer().getRemoteId(), - e.getMessage()); - } - } + downloadAttachment(attachment); } } if (message.getProfileKey().isPresent() && message.getProfileKey().get().length == 32) { @@ -1608,15 +1447,8 @@ public class Manager implements Closeable { if (message.getPreviews().isPresent()) { final List previews = message.getPreviews().get(); for (SignalServiceDataMessage.Preview preview : previews) { - if (preview.getImage().isPresent() && preview.getImage().get().isPointer()) { - SignalServiceAttachmentPointer attachment = preview.getImage().get().asPointer(); - try { - retrieveAttachment(attachment); - } catch (IOException | InvalidMessageException | MissingConfigurationException e) { - logger.warn("Failed to retrieve preview image ({}), ignoring: {}", - attachment.getRemoteId(), - e.getMessage()); - } + if (preview.getImage().isPresent()) { + downloadAttachment(preview.getImage().get()); } } } @@ -1624,15 +1456,9 @@ public class Manager implements Closeable { final SignalServiceDataMessage.Quote quote = message.getQuote().get(); for (SignalServiceDataMessage.Quote.QuotedAttachment quotedAttachment : quote.getAttachments()) { - final SignalServiceAttachment attachment = quotedAttachment.getThumbnail(); - if (attachment != null && attachment.isPointer()) { - try { - retrieveAttachment(attachment.asPointer()); - } catch (IOException | InvalidMessageException | MissingConfigurationException e) { - logger.warn("Failed to retrieve quote attachment thumbnail ({}), ignoring: {}", - attachment.asPointer().getRemoteId(), - e.getMessage()); - } + final SignalServiceAttachment thumbnail = quotedAttachment.getThumbnail(); + if (thumbnail != null) { + downloadAttachment(thumbnail); } } } @@ -1682,11 +1508,7 @@ public class Manager implements Closeable { storeProfileKeysFromMembers(group); final String avatar = group.getAvatar(); if (avatar != null && !avatar.isEmpty()) { - try { - retrieveGroupAvatar(groupId, groupSecretParams, avatar); - } catch (IOException e) { - logger.warn("Failed to download group avatar, ignoring: {}", e.getMessage()); - } + downloadGroupAvatar(groupId, groupSecretParams, avatar); } } groupInfoV2.setGroup(group); @@ -1737,7 +1559,7 @@ public class Manager implements Closeable { try { action.execute(this); } catch (Throwable e) { - e.printStackTrace(); + logger.warn("Message action failed.", e); } } } @@ -1782,7 +1604,7 @@ public class Manager implements Closeable { try { action.execute(this); } catch (Throwable e) { - e.printStackTrace(); + logger.warn("Message action failed.", e); } } account.save(); @@ -1818,7 +1640,7 @@ public class Manager implements Closeable { try { action.execute(this); } catch (Throwable e) { - e.printStackTrace(); + logger.warn("Message action failed.", e); } } } else { @@ -1829,7 +1651,11 @@ public class Manager implements Closeable { } } account.save(); - if (!isMessageBlocked(envelope, content)) { + if (isMessageBlocked(envelope, content)) { + logger.info("Ignoring a message from blocked user/group: {}", envelope.getTimestamp()); + } else if (isNotAGroupMember(envelope, content)) { + logger.info("Ignoring a message from a non group member: {}", envelope.getTimestamp()); + } else { handler.handleMessage(envelope, content, exception); } if (!(exception instanceof org.whispersystems.libsignal.UntrustedIdentityException)) { @@ -1856,18 +1682,43 @@ public class Manager implements Closeable { return true; } + if (content != null && content.getDataMessage().isPresent()) { + SignalServiceDataMessage message = content.getDataMessage().get(); + if (message.getGroupContext().isPresent()) { + GroupId groupId = GroupUtils.getGroupId(message.getGroupContext().get()); + GroupInfo group = getGroup(groupId); + if (group != null && group.isBlocked()) { + return true; + } + } + } + return false; + } + + private boolean isNotAGroupMember( + SignalServiceEnvelope envelope, SignalServiceContent content + ) { + SignalServiceAddress source; + if (!envelope.isUnidentifiedSender() && envelope.hasSource()) { + source = envelope.getSourceAddress(); + } else if (content != null) { + source = content.getSender(); + } else { + return false; + } + if (content != null && content.getDataMessage().isPresent()) { SignalServiceDataMessage message = content.getDataMessage().get(); if (message.getGroupContext().isPresent()) { if (message.getGroupContext().get().getGroupV1().isPresent()) { SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get(); - if (groupInfo.getType() != SignalServiceGroup.Type.DELIVER) { + if (groupInfo.getType() == SignalServiceGroup.Type.QUIT) { return false; } } GroupId groupId = GroupUtils.getGroupId(message.getGroupContext().get()); GroupInfo group = getGroup(groupId); - if (group != null && group.isBlocked()) { + if (group != null && !group.isMember(source)) { return true; } } @@ -1931,9 +1782,9 @@ public class Manager implements Closeable { File tmpFile = null; try { tmpFile = IOUtils.createTempFile(); - try (InputStream attachmentAsStream = retrieveAttachmentAsStream(syncMessage.getGroups() - .get() - .asPointer(), tmpFile)) { + final SignalServiceAttachment groupsMessage = syncMessage.getGroups().get(); + try (InputStream attachmentAsStream = retrieveAttachmentAsStream(groupsMessage.asPointer(), + tmpFile)) { DeviceGroupsInputStream s = new DeviceGroupsInputStream(attachmentAsStream); DeviceGroup g; while ((g = s.read()) != null) { @@ -1959,7 +1810,7 @@ public class Manager implements Closeable { } if (g.getAvatar().isPresent()) { - retrieveGroupAvatarAttachment(g.getAvatar().get(), syncGroup.getGroupId()); + downloadGroupAvatar(g.getAvatar().get(), syncGroup.getGroupId()); } syncGroup.inboxPosition = g.getInboxPosition().orNull(); syncGroup.archived = g.isArchived(); @@ -1971,7 +1822,6 @@ public class Manager implements Closeable { logger.warn("Failed to handle received sync groups “{}”, ignoring: {}", tmpFile, e.getMessage()); - e.printStackTrace(); } finally { if (tmpFile != null) { try { @@ -2047,12 +1897,14 @@ public class Manager implements Closeable { account.getContactStore().updateContact(contact); if (c.getAvatar().isPresent()) { - retrieveContactAvatarAttachment(c.getAvatar().get(), contact.number); + downloadContactAvatar(c.getAvatar().get(), contact.getAddress()); } } } } catch (Exception e) { - e.printStackTrace(); + logger.warn("Failed to handle received sync contacts “{}”, ignoring: {}", + tmpFile, + e.getMessage()); } finally { if (tmpFile != null) { try { @@ -2099,58 +1951,83 @@ public class Manager implements Closeable { return actions; } - private File getContactAvatarFile(String number) { - return new File(pathConfig.getAvatarsPath(), "contact-" + number); - } - - private File retrieveContactAvatarAttachment( - SignalServiceAttachment attachment, String number - ) throws IOException, InvalidMessageException, MissingConfigurationException { - IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath()); - if (attachment.isPointer()) { - SignalServiceAttachmentPointer pointer = attachment.asPointer(); - return retrieveAttachment(pointer, getContactAvatarFile(number), false); - } else { - SignalServiceAttachmentStream stream = attachment.asStream(); - return AttachmentUtils.retrieveAttachment(stream, getContactAvatarFile(number)); + private void downloadContactAvatar(SignalServiceAttachment avatar, SignalServiceAddress address) { + try { + avatarStore.storeContactAvatar(address, outputStream -> retrieveAttachment(avatar, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for contact {}, ignoring: {}", address, e.getMessage()); } } - private File getGroupAvatarFile(GroupId groupId) { - return new File(pathConfig.getAvatarsPath(), "group-" + groupId.toBase64().replace("/", "_")); - } - - private File retrieveGroupAvatarAttachment( - SignalServiceAttachment attachment, GroupId groupId - ) throws IOException, InvalidMessageException, MissingConfigurationException { - IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath()); - if (attachment.isPointer()) { - SignalServiceAttachmentPointer pointer = attachment.asPointer(); - return retrieveAttachment(pointer, getGroupAvatarFile(groupId), false); - } else { - SignalServiceAttachmentStream stream = attachment.asStream(); - return AttachmentUtils.retrieveAttachment(stream, getGroupAvatarFile(groupId)); + private void downloadGroupAvatar(SignalServiceAttachment avatar, GroupId groupId) { + try { + avatarStore.storeGroupAvatar(groupId, outputStream -> retrieveAttachment(avatar, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); } } - private File retrieveGroupAvatar( - GroupId groupId, GroupSecretParams groupSecretParams, String cdnKey + private void downloadGroupAvatar(GroupId groupId, GroupSecretParams groupSecretParams, String cdnKey) { + try { + avatarStore.storeGroupAvatar(groupId, + outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); + } + } + + private void downloadProfileAvatar( + SignalServiceAddress address, String avatarPath, ProfileKey profileKey + ) { + try { + avatarStore.storeProfileAvatar(address, + outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream)); + } catch (Throwable e) { + logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage()); + } + } + + public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { + return attachmentStore.getAttachmentFile(attachmentId); + } + + private void downloadAttachment(final SignalServiceAttachment attachment) { + if (!attachment.isPointer()) { + logger.warn("Invalid state, can't store an attachment stream."); + } + + SignalServiceAttachmentPointer pointer = attachment.asPointer(); + if (pointer.getPreview().isPresent()) { + final byte[] preview = pointer.getPreview().get(); + try { + attachmentStore.storeAttachmentPreview(pointer.getRemoteId(), + outputStream -> outputStream.write(preview, 0, preview.length)); + } catch (IOException e) { + logger.warn("Failed to download attachment preview, ignoring: {}", e.getMessage()); + } + } + + try { + attachmentStore.storeAttachment(pointer.getRemoteId(), + outputStream -> retrieveAttachmentPointer(pointer, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage()); + } + } + + private void retrieveGroupV2Avatar( + GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream ) throws IOException { - IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath()); - File outputFile = getGroupAvatarFile(groupId); GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); File tmpFile = IOUtils.createTempFile(); - tmpFile.deleteOnExit(); try (InputStream input = messageReceiver.retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { byte[] encryptedData = IOUtils.readFully(input); byte[] decryptedData = groupOperations.decryptAvatar(encryptedData); - try (OutputStream output = new FileOutputStream(outputFile)) { - output.write(decryptedData); - } + outputStream.write(decryptedData); } finally { try { Files.delete(tmpFile.toPath()); @@ -2160,26 +2037,18 @@ public class Manager implements Closeable { e.getMessage()); } } - return outputFile; } - private File getProfileAvatarFile(SignalServiceAddress address) { - return new File(pathConfig.getAvatarsPath(), "profile-" + address.getLegacyIdentifier()); - } - - private File retrieveProfileAvatar( - SignalServiceAddress address, String avatarPath, ProfileKey profileKey + private void retrieveProfileAvatar( + String avatarPath, ProfileKey profileKey, OutputStream outputStream ) throws IOException { - IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath()); - File outputFile = getProfileAvatarFile(address); - File tmpFile = IOUtils.createTempFile(); try (InputStream input = messageReceiver.retrieveProfileAvatar(avatarPath, tmpFile, profileKey, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ... - IOUtils.copyStreamToFile(input, outputFile, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); + IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE); } finally { try { Files.delete(tmpFile.toPath()); @@ -2189,37 +2058,28 @@ public class Manager implements Closeable { e.getMessage()); } } - return outputFile; } - public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) { - return new File(pathConfig.getAttachmentsPath(), attachmentId.toString()); - } - - private File retrieveAttachment(SignalServiceAttachmentPointer pointer) throws IOException, InvalidMessageException, MissingConfigurationException { - IOUtils.createPrivateDirectories(pathConfig.getAttachmentsPath()); - return retrieveAttachment(pointer, getAttachmentFile(pointer.getRemoteId()), true); - } - - private File retrieveAttachment( - SignalServiceAttachmentPointer pointer, File outputFile, boolean storePreview - ) throws IOException, InvalidMessageException, MissingConfigurationException { - if (storePreview && pointer.getPreview().isPresent()) { - File previewFile = new File(outputFile + ".preview"); - try (OutputStream output = new FileOutputStream(previewFile)) { - byte[] preview = pointer.getPreview().get(); - output.write(preview, 0, preview.length); - } catch (FileNotFoundException e) { - e.printStackTrace(); - return null; - } + private void retrieveAttachment( + final SignalServiceAttachment attachment, final OutputStream outputStream + ) throws IOException { + if (attachment.isPointer()) { + SignalServiceAttachmentPointer pointer = attachment.asPointer(); + retrieveAttachmentPointer(pointer, outputStream); + } else { + SignalServiceAttachmentStream stream = attachment.asStream(); + IOUtils.copyStream(stream.getInputStream(), outputStream); } + } + private void retrieveAttachmentPointer( + SignalServiceAttachmentPointer pointer, OutputStream outputStream + ) throws IOException { File tmpFile = IOUtils.createTempFile(); - try (InputStream input = messageReceiver.retrieveAttachment(pointer, - tmpFile, - ServiceConfig.MAX_ATTACHMENT_SIZE)) { - IOUtils.copyStreamToFile(input, outputFile); + try (InputStream input = retrieveAttachmentAsStream(pointer, tmpFile)) { + IOUtils.copyStream(input, outputStream); + } catch (MissingConfigurationException | InvalidMessageException e) { + throw new IOException(e); } finally { try { Files.delete(tmpFile.toPath()); @@ -2229,7 +2089,6 @@ public class Manager implements Closeable { e.getMessage()); } } - return outputFile; } private InputStream retrieveAttachmentAsStream( @@ -2300,7 +2159,7 @@ public class Manager implements Closeable { ProfileKey profileKey = account.getProfileStore().getProfileKey(record.getAddress()); out.write(new DeviceContact(record.getAddress(), Optional.fromNullable(record.name), - createContactAvatarAttachment(record.number), + createContactAvatarAttachment(record.getAddress()), Optional.fromNullable(record.color), Optional.fromNullable(verifiedMessage), Optional.fromNullable(profileKey), @@ -2419,7 +2278,7 @@ public class Manager implements Closeable { try { sendVerifiedMessage(address, id.getIdentityKey(), TrustLevel.TRUSTED_VERIFIED); } catch (IOException | UntrustedIdentityException e) { - e.printStackTrace(); + logger.warn("Failed to send verification sync message: {}", e.getMessage()); } account.save(); return true; @@ -2449,7 +2308,7 @@ public class Manager implements Closeable { try { sendVerifiedMessage(address, id.getIdentityKey(), TrustLevel.TRUSTED_VERIFIED); } catch (IOException | UntrustedIdentityException e) { - e.printStackTrace(); + logger.warn("Failed to send verification sync message: {}", e.getMessage()); } account.save(); return true; @@ -2475,7 +2334,7 @@ public class Manager implements Closeable { try { sendVerifiedMessage(address, id.getIdentityKey(), TrustLevel.TRUSTED_UNVERIFIED); } catch (IOException | UntrustedIdentityException e) { - e.printStackTrace(); + logger.warn("Failed to send verification sync message: {}", e.getMessage()); } } } @@ -2493,10 +2352,6 @@ public class Manager implements Closeable { theirIdentityKey); } - void saveAccount() { - account.save(); - } - public SignalServiceAddress canonicalizeAndResolveSignalServiceAddress(String identifier) throws InvalidNumberException { String canonicalizedNumber = UuidUtil.isUuid(identifier) ? identifier diff --git a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 8a2320e0..d39da8a3 100644 --- a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2Change import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.UuidUtil; +import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; @@ -47,7 +48,7 @@ import java.util.stream.Collectors; public class GroupHelper { - final static Logger logger = LoggerFactory.getLogger(GroupHelper.class); + private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class); private final ProfileKeyCredentialProvider profileKeyCredentialProvider; @@ -99,7 +100,7 @@ public class GroupHelper { } public GroupInfoV2 createGroupV2( - String name, Collection members, String avatarFile + String name, Collection members, File avatarFile ) throws IOException { final byte[] avatarBytes = readAvatarBytes(avatarFile); final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes); @@ -132,7 +133,7 @@ public class GroupHelper { return g; } - private byte[] readAvatarBytes(final String avatarFile) throws IOException { + private byte[] readAvatarBytes(final File avatarFile) throws IOException { final byte[] avatarBytes; try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) { avatarBytes = avatar == null ? null : IOUtils.readFully(avatar); @@ -194,7 +195,7 @@ public class GroupHelper { } public Pair updateGroupV2( - GroupInfoV2 groupInfoV2, String name, String avatarFile + GroupInfoV2 groupInfoV2, String name, File avatarFile ) throws IOException { final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); diff --git a/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 6d592573..236c7996 100644 --- a/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -59,7 +59,7 @@ import java.util.stream.Collectors; public class SignalAccount implements Closeable { - final static Logger logger = LoggerFactory.getLogger(SignalAccount.class); + private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class); private final ObjectMapper jsonProcessor = new ObjectMapper(); private final FileChannel fileChannel; @@ -138,6 +138,8 @@ public class SignalAccount implements Closeable { account.registered = false; + account.migrateLegacyConfigs(); + return account; } @@ -179,6 +181,8 @@ public class SignalAccount implements Closeable { account.registered = true; account.isMultiDevice = true; + account.migrateLegacyConfigs(); + return account; } diff --git a/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java b/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java index fdcd28a3..5c06aeee 100644 --- a/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java +++ b/src/main/java/org/asamk/signal/manager/storage/groups/JsonGroupStore.java @@ -37,7 +37,7 @@ import java.util.Map; public class JsonGroupStore { - final static Logger logger = LoggerFactory.getLogger(JsonGroupStore.class); + private final static Logger logger = LoggerFactory.getLogger(JsonGroupStore.class); private static final ObjectMapper jsonProcessor = new ObjectMapper(); public File groupCachePath; diff --git a/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java b/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java index 6c20cf62..8ea723cc 100644 --- a/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java +++ b/src/main/java/org/asamk/signal/manager/storage/messageCache/CachedMessage.java @@ -11,7 +11,7 @@ import java.nio.file.Files; public final class CachedMessage { - final static Logger logger = LoggerFactory.getLogger(CachedMessage.class); + private final static Logger logger = LoggerFactory.getLogger(CachedMessage.class); private final File file; diff --git a/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java b/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java index 4e48ee76..3e728c28 100644 --- a/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java +++ b/src/main/java/org/asamk/signal/manager/storage/messageCache/MessageCache.java @@ -18,7 +18,7 @@ import java.util.stream.Stream; public class MessageCache { - final static Logger logger = LoggerFactory.getLogger(MessageCache.class); + private final static Logger logger = LoggerFactory.getLogger(MessageCache.class); private final File messageCachePath; diff --git a/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java b/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java index 48a38578..1ec2eeaa 100644 --- a/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java +++ b/src/main/java/org/asamk/signal/manager/storage/profiles/SignalProfile.java @@ -5,8 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; -import java.io.File; - public class SignalProfile { @JsonProperty @@ -15,8 +13,6 @@ public class SignalProfile { @JsonProperty private final String name; - private final File avatarFile; - @JsonProperty private final String unidentifiedAccess; @@ -29,14 +25,12 @@ public class SignalProfile { 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; this.unidentifiedAccess = unidentifiedAccess; this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; this.capabilities = new Capabilities(); @@ -54,7 +48,6 @@ public class SignalProfile { ) { this.identityKey = identityKey; this.name = name; - this.avatarFile = null; this.unidentifiedAccess = unidentifiedAccess; this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; this.capabilities = capabilities; @@ -68,10 +61,6 @@ public class SignalProfile { return name; } - public File getAvatarFile() { - return avatarFile; - } - public String getUnidentifiedAccess() { return unidentifiedAccess; } @@ -94,7 +83,6 @@ public class SignalProfile { + name + '\'' + ", avatarFile=" - + avatarFile + ", unidentifiedAccess='" + unidentifiedAccess + '\'' diff --git a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java index 5bc1c11f..19131e13 100644 --- a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java +++ b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonIdentityKeyStore.java @@ -29,7 +29,7 @@ import java.util.UUID; public class JsonIdentityKeyStore implements IdentityKeyStore { - final static Logger logger = LoggerFactory.getLogger(JsonIdentityKeyStore.class); + private final static Logger logger = LoggerFactory.getLogger(JsonIdentityKeyStore.class); private final List identities = new ArrayList<>(); diff --git a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java index 4d884c3e..9ec4b64f 100644 --- a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java +++ b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonPreKeyStore.java @@ -21,7 +21,7 @@ import java.util.Map; class JsonPreKeyStore implements PreKeyStore { - final static Logger logger = LoggerFactory.getLogger(JsonPreKeyStore.class); + private final static Logger logger = LoggerFactory.getLogger(JsonPreKeyStore.class); private final Map store = new HashMap<>(); diff --git a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java index 6e300214..79790598 100644 --- a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java +++ b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSessionStore.java @@ -26,7 +26,7 @@ import java.util.UUID; class JsonSessionStore implements SessionStore { - final static Logger logger = LoggerFactory.getLogger(JsonSessionStore.class); + private final static Logger logger = LoggerFactory.getLogger(JsonSessionStore.class); private final List sessions = new ArrayList<>(); diff --git a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java index 5eae4500..00543620 100644 --- a/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java +++ b/src/main/java/org/asamk/signal/manager/storage/protocol/JsonSignedPreKeyStore.java @@ -23,7 +23,7 @@ import java.util.Map; class JsonSignedPreKeyStore implements SignedPreKeyStore { - final static Logger logger = LoggerFactory.getLogger(JsonSignedPreKeyStore.class); + private final static Logger logger = LoggerFactory.getLogger(JsonSignedPreKeyStore.class); private final Map store = new HashMap<>(); diff --git a/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java b/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java index b9a97073..ec043cfd 100644 --- a/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java +++ b/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java @@ -4,15 +4,11 @@ import org.asamk.signal.manager.AttachmentInvalidException; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.util.ArrayList; import java.util.List; @@ -34,19 +30,23 @@ public class AttachmentUtils { } public static SignalServiceAttachmentStream createAttachment(File attachmentFile) throws IOException { - InputStream attachmentStream = new FileInputStream(attachmentFile); - final long attachmentSize = attachmentFile.length(); - final String mime = Utils.getFileMimeType(attachmentFile, "application/octet-stream"); + final StreamDetails streamDetails = Utils.createStreamDetailsFromFile(attachmentFile); + return createAttachment(streamDetails, Optional.of(attachmentFile.getName())); + } + + public static SignalServiceAttachmentStream createAttachment( + StreamDetails streamDetails, Optional name + ) { // TODO mabybe add a parameter to set the voiceNote, borderless, preview, width, height and caption option final long uploadTimestamp = System.currentTimeMillis(); Optional preview = Optional.absent(); Optional caption = Optional.absent(); Optional blurHash = Optional.absent(); final Optional resumableUploadSpec = Optional.absent(); - return new SignalServiceAttachmentStream(attachmentStream, - mime, - attachmentSize, - Optional.of(attachmentFile.getName()), + return new SignalServiceAttachmentStream(streamDetails.getStream(), + streamDetails.getContentType(), + streamDetails.getLength(), + name, false, false, preview, @@ -59,21 +59,4 @@ public class AttachmentUtils { null, resumableUploadSpec); } - - public static File retrieveAttachment(SignalServiceAttachmentStream stream, File outputFile) throws IOException { - InputStream input = stream.getInputStream(); - - try (OutputStream output = new FileOutputStream(outputFile)) { - byte[] buffer = new byte[4096]; - int read; - - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - } - } catch (FileNotFoundException e) { - e.printStackTrace(); - return null; - } - return outputFile; - } } diff --git a/src/main/java/org/asamk/signal/manager/util/IOUtils.java b/src/main/java/org/asamk/signal/manager/util/IOUtils.java index 06f8aa22..8f47c9f4 100644 --- a/src/main/java/org/asamk/signal/manager/util/IOUtils.java +++ b/src/main/java/org/asamk/signal/manager/util/IOUtils.java @@ -1,10 +1,8 @@ package org.asamk.signal.manager.util; -import org.whispersystems.signalservice.internal.util.Util; - import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileOutputStream; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -22,12 +20,14 @@ import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; public class IOUtils { public static File createTempFile() throws IOException { - return File.createTempFile("signal_tmp_", ".tmp"); + final File tempFile = File.createTempFile("signal-cli_tmp_", ".tmp"); + tempFile.deleteOnExit(); + return tempFile; } public static byte[] readFully(InputStream in) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Util.copy(in, baos); + IOUtils.copyStream(in, baos); return baos.toByteArray(); } @@ -55,18 +55,22 @@ public class IOUtils { } } - public static void copyStreamToFile(InputStream input, File outputFile) throws IOException { - copyStreamToFile(input, outputFile, 8192); + public static void copyFileToStream(File inputFile, OutputStream output) throws IOException { + try (InputStream inputStream = new FileInputStream(inputFile)) { + copyStream(inputStream, output); + } } - public static void copyStreamToFile(InputStream input, File outputFile, int bufferSize) throws IOException { - try (OutputStream output = new FileOutputStream(outputFile)) { - byte[] buffer = new byte[bufferSize]; - int read; + public static void copyStream(InputStream input, OutputStream output) throws IOException { + copyStream(input, output, 4096); + } - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - } + public static void copyStream(InputStream input, OutputStream output, int bufferSize) throws IOException { + byte[] buffer = new byte[bufferSize]; + int read; + + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); } } } diff --git a/src/main/java/org/asamk/signal/manager/util/KeyUtils.java b/src/main/java/org/asamk/signal/manager/util/KeyUtils.java index d8861b1b..171e7a42 100644 --- a/src/main/java/org/asamk/signal/manager/util/KeyUtils.java +++ b/src/main/java/org/asamk/signal/manager/util/KeyUtils.java @@ -5,12 +5,19 @@ import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.IdentityKeyPair; +import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.ecc.Curve; import org.whispersystems.libsignal.ecc.ECKeyPair; import org.whispersystems.libsignal.ecc.ECPrivateKey; +import org.whispersystems.libsignal.state.PreKeyRecord; +import org.whispersystems.libsignal.state.SignedPreKeyRecord; +import org.whispersystems.libsignal.util.Medium; import org.whispersystems.signalservice.api.kbs.MasterKey; import org.whispersystems.util.Base64; +import java.util.ArrayList; +import java.util.List; + public class KeyUtils { private KeyUtils() { @@ -24,6 +31,31 @@ public class KeyUtils { return new IdentityKeyPair(djbIdentityKey, djbPrivateKey); } + public static List generatePreKeyRecords(final int offset, final int batchSize) { + List records = new ArrayList<>(batchSize); + for (int i = 0; i < batchSize; i++) { + int preKeyId = (offset + i) % Medium.MAX_VALUE; + ECKeyPair keyPair = Curve.generateKeyPair(); + PreKeyRecord record = new PreKeyRecord(preKeyId, keyPair); + + records.add(record); + } + return records; + } + + public static SignedPreKeyRecord generateSignedPreKeyRecord( + final IdentityKeyPair identityKeyPair, final int signedPreKeyId + ) { + ECKeyPair keyPair = Curve.generateKeyPair(); + byte[] signature; + try { + signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), keyPair.getPublicKey().serialize()); + } catch (InvalidKeyException e) { + throw new AssertionError(e); + } + return new SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature); + } + public static String createSignalingKey() { return getSecret(52); } diff --git a/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java b/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java new file mode 100644 index 00000000..13ce3cb2 --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/util/ProfileUtils.java @@ -0,0 +1,45 @@ +package org.asamk.signal.manager.util; + +import org.asamk.signal.manager.storage.profiles.SignalProfile; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; +import org.whispersystems.signalservice.api.crypto.ProfileCipher; +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.util.Base64; + +import java.io.IOException; + +public class ProfileUtils { + + public static SignalProfile decryptProfile( + final ProfileKey profileKey, final SignalServiceProfile encryptedProfile + ) { + ProfileCipher profileCipher = new ProfileCipher(profileKey); + try { + String name; + try { + name = encryptedProfile.getName() == null + ? null + : new String(profileCipher.decryptName(Base64.decode(encryptedProfile.getName()))); + } catch (IOException e) { + name = null; + } + String unidentifiedAccess; + try { + unidentifiedAccess = encryptedProfile.getUnidentifiedAccess() == null + || !profileCipher.verifyUnidentifiedAccess(Base64.decode(encryptedProfile.getUnidentifiedAccess())) + ? null + : encryptedProfile.getUnidentifiedAccess(); + } catch (IOException e) { + unidentifiedAccess = null; + } + return new SignalProfile(encryptedProfile.getIdentityKey(), + name, + unidentifiedAccess, + encryptedProfile.isUnrestrictedUnidentifiedAccess(), + encryptedProfile.getCapabilities()); + } catch (InvalidCiphertextException e) { + return null; + } + } +} diff --git a/src/main/java/org/asamk/signal/manager/util/StickerUtils.java b/src/main/java/org/asamk/signal/manager/util/StickerUtils.java new file mode 100644 index 00000000..fd5ce77b --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/util/StickerUtils.java @@ -0,0 +1,113 @@ +package org.asamk.signal.manager.util; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.manager.JsonStickerPack; +import org.asamk.signal.manager.StickerPackInvalidException; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class StickerUtils { + + public static SignalServiceStickerManifestUpload getSignalServiceStickerManifestUpload( + final File file + ) throws IOException, StickerPackInvalidException { + ZipFile zip = null; + String rootPath = null; + + if (file.getName().endsWith(".zip")) { + zip = new ZipFile(file); + } else if (file.getName().equals("manifest.json")) { + rootPath = file.getParent(); + } else { + throw new StickerPackInvalidException("Could not find manifest.json"); + } + + JsonStickerPack pack = parseStickerPack(rootPath, zip); + + if (pack.stickers == null) { + throw new StickerPackInvalidException("Must set a 'stickers' field."); + } + + if (pack.stickers.isEmpty()) { + throw new StickerPackInvalidException("Must include stickers."); + } + + List stickers = new ArrayList<>(pack.stickers.size()); + for (JsonStickerPack.JsonSticker sticker : pack.stickers) { + if (sticker.file == null) { + throw new StickerPackInvalidException("Must set a 'file' field on each sticker."); + } + + Pair data; + try { + data = getInputStreamAndLength(rootPath, zip, sticker.file); + } catch (IOException ignored) { + throw new StickerPackInvalidException("Could not find find " + sticker.file); + } + + String contentType = Utils.getFileMimeType(new File(sticker.file), null); + SignalServiceStickerManifestUpload.StickerInfo stickerInfo = new SignalServiceStickerManifestUpload.StickerInfo( + data.first(), + data.second(), + Optional.fromNullable(sticker.emoji).or(""), + contentType); + stickers.add(stickerInfo); + } + + SignalServiceStickerManifestUpload.StickerInfo cover = null; + if (pack.cover != null) { + if (pack.cover.file == null) { + throw new StickerPackInvalidException("Must set a 'file' field on the cover."); + } + + Pair data; + try { + data = getInputStreamAndLength(rootPath, zip, pack.cover.file); + } catch (IOException ignored) { + throw new StickerPackInvalidException("Could not find find " + pack.cover.file); + } + + String contentType = Utils.getFileMimeType(new File(pack.cover.file), null); + cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(), + data.second(), + Optional.fromNullable(pack.cover.emoji).or(""), + contentType); + } + + return new SignalServiceStickerManifestUpload(pack.title, pack.author, cover, stickers); + } + + private static JsonStickerPack parseStickerPack(String rootPath, ZipFile zip) throws IOException { + InputStream inputStream; + if (zip != null) { + inputStream = zip.getInputStream(zip.getEntry("manifest.json")); + } else { + inputStream = new FileInputStream((new File(rootPath, "manifest.json"))); + } + return new ObjectMapper().readValue(inputStream, JsonStickerPack.class); + } + + private static Pair getInputStreamAndLength( + final String rootPath, final ZipFile zip, final String subfile + ) throws IOException { + if (zip != null) { + final ZipEntry entry = zip.getEntry(subfile); + return new Pair<>(zip.getInputStream(entry), entry.getSize()); + } else { + final File file = new File(rootPath, subfile); + return new Pair<>(new FileInputStream(file), file.length()); + } + } + +} diff --git a/src/main/java/org/asamk/signal/manager/util/Utils.java b/src/main/java/org/asamk/signal/manager/util/Utils.java index e68b5ce3..65f2811b 100644 --- a/src/main/java/org/asamk/signal/manager/util/Utils.java +++ b/src/main/java/org/asamk/signal/manager/util/Utils.java @@ -36,10 +36,7 @@ public class Utils { public static StreamDetails createStreamDetailsFromFile(File file) throws IOException { InputStream stream = new FileInputStream(file); final long size = file.length(); - String mime = Files.probeContentType(file.toPath()); - if (mime == null) { - mime = "application/octet-stream"; - } + final String mime = getFileMimeType(file, "application/octet-stream"); return new StreamDetails(stream, mime, size); }