]> nmode's Git Repositories - signal-cli/commitdiff
Merge branch master into dbus_updateConfiguration
authorJohn Freed <okgithub@johnfreed.com>
Thu, 14 Oct 2021 13:24:07 +0000 (15:24 +0200)
committerJohn Freed <okgithub@johnfreed.com>
Thu, 14 Oct 2021 13:24:07 +0000 (15:24 +0200)
32 files changed:
build.gradle.kts
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties
gradlew
lib/build.gradle.kts
lib/src/main/java/org/asamk/signal/manager/DeviceLinkInfo.java
lib/src/main/java/org/asamk/signal/manager/Manager.java
lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java
lib/src/main/java/org/asamk/signal/manager/SignalWebSocketHealthMonitor.java
lib/src/main/java/org/asamk/signal/manager/api/Group.java
lib/src/main/java/org/asamk/signal/manager/api/UpdateGroup.java [new file with mode: 0644]
lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/IdentityHelper.java [new file with mode: 0644]
lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java [deleted file]
lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java [deleted file]
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfo.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java
man/signal-cli-dbus.5.adoc
man/signal-cli.1.adoc
src/main/java/org/asamk/Signal.java
src/main/java/org/asamk/signal/ReceiveMessageHandler.java
src/main/java/org/asamk/signal/commands/BlockCommand.java
src/main/java/org/asamk/signal/commands/ListGroupsCommand.java
src/main/java/org/asamk/signal/commands/UnblockCommand.java
src/main/java/org/asamk/signal/commands/UpdateGroupCommand.java
src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java
src/main/java/org/asamk/signal/dbus/DbusProperty.java
src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java

index 51b2ef755f9ab1f6ea0fa1f0ec773709b2b8411c..5d18e9e0ee46c700b9252b1eae8b0e663cea851a 100644 (file)
@@ -3,7 +3,7 @@ plugins {
     application
     eclipse
     `check-lib-versions`
-    id("org.graalvm.buildtools.native") version "0.9.5"
+    id("org.graalvm.buildtools.native") version "0.9.6"
 }
 
 version = "0.9.0"
index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..7454180f2ae8848c63b8b4dea2cb829da983f2fa 100644 (file)
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
index f371643eed77c0489147fbe387473065005d193a..ffed3a254e91df704a9acc0f2745c0e340d9b582 100644 (file)
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 4f906e0c811fc9e230eb44819f509cd0627f2600..1b6c787337ffb79f0e3cf8b1e9f00f680a959de1 100755 (executable)
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
-#!/usr/bin/env sh
+#!/bin/sh
 
 #
-# Copyright 2015 the original author or authors.
+# Copyright © 2015-2021 the original authors.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
 #
 
 ##############################################################################
-##
-##  Gradle start up script for UN*X
-##
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
 ##############################################################################
 
 # Attempt to set APP_HOME
+
 # Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
-    ls=`ls -ld "$PRG"`
-    link=`expr "$ls" : '.*-> \(.*\)$'`
-    if expr "$link" : '/.*' > /dev/null; then
-        PRG="$link"
-    else
-        PRG=`dirname "$PRG"`"/$link"
-    fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
 done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
 
 APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
+APP_BASE_NAME=${0##*/}
 
 # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
 DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
 
 # Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
 
 warn () {
     echo "$*"
-}
+} >&2
 
 die () {
     echo
     echo "$*"
     echo
     exit 1
-}
+} >&2
 
 # OS specific support (must be 'true' or 'false').
 cygwin=false
 msys=false
 darwin=false
 nonstop=false
-case "`uname`" in
-  CYGWIN* )
-    cygwin=true
-    ;;
-  Darwin* )
-    darwin=true
-    ;;
-  MINGW* )
-    msys=true
-    ;;
-  NONSTOP* )
-    nonstop=true
-    ;;
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
 esac
 
 CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
 if [ -n "$JAVA_HOME" ] ; then
     if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
         # IBM's JDK on AIX uses strange locations for the executables
-        JAVACMD="$JAVA_HOME/jre/sh/java"
+        JAVACMD=$JAVA_HOME/jre/sh/java
     else
-        JAVACMD="$JAVA_HOME/bin/java"
+        JAVACMD=$JAVA_HOME/bin/java
     fi
     if [ ! -x "$JAVACMD" ] ; then
         die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
 location of your Java installation."
     fi
 else
-    JAVACMD="java"
+    JAVACMD=java
     which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
 
 Please set the JAVA_HOME variable in your environment to match the
@@ -106,80 +140,95 @@ location of your Java installation."
 fi
 
 # Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
-    MAX_FD_LIMIT=`ulimit -H -n`
-    if [ $? -eq 0 ] ; then
-        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
-            MAX_FD="$MAX_FD_LIMIT"
-        fi
-        ulimit -n $MAX_FD
-        if [ $? -ne 0 ] ; then
-            warn "Could not set maximum file descriptor limit: $MAX_FD"
-        fi
-    else
-        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
-    fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
 fi
 
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
-    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
 
 # For Cygwin or MSYS, switch paths to Windows format before running java
-if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
-    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
-    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
-
-    JAVACMD=`cygpath --unix "$JAVACMD"`
-
-    # We build the pattern for arguments to be converted via cygpath
-    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
-    SEP=""
-    for dir in $ROOTDIRSRAW ; do
-        ROOTDIRS="$ROOTDIRS$SEP$dir"
-        SEP="|"
-    done
-    OURCYGPATTERN="(^($ROOTDIRS))"
-    # Add a user-defined pattern to the cygpath arguments
-    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
-        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
-    fi
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
     # Now convert the arguments - kludge to limit ourselves to /bin/sh
-    i=0
-    for arg in "$@" ; do
-        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
-        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
-
-        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
-            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
-        else
-            eval `echo args$i`="\"$arg\""
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
         fi
-        i=`expr $i + 1`
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
     done
-    case $i in
-        0) set -- ;;
-        1) set -- "$args0" ;;
-        2) set -- "$args0" "$args1" ;;
-        3) set -- "$args0" "$args1" "$args2" ;;
-        4) set -- "$args0" "$args1" "$args2" "$args3" ;;
-        5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
-        6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
-        7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
-        8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
-        9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
-    esac
 fi
 
-# Escape application args
-save () {
-    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
-    echo " "
-}
-APP_ARGS=`save "$@"`
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
 
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
 
 exec "$JAVACMD" "$@"
index 6e528805446ba9e6bcb6cbc327efe46249d569ba..e4e7dc202fa731afb4d1365962131a80040c0238 100644 (file)
@@ -14,7 +14,7 @@ repositories {
 }
 
 dependencies {
-    api("com.github.turasa:signal-service-java:2.15.3_unofficial_28")
+    api("com.github.turasa:signal-service-java:2.15.3_unofficial_29")
     implementation("com.google.protobuf:protobuf-javalite:3.10.0")
     implementation("org.bouncycastle:bcprov-jdk15on:1.69")
     implementation("org.slf4j:slf4j-api:1.7.30")
index 1f9d10ff897248f61c08d8f5c1023fef27544ff2..3ba1ef201d85e5916914c962cd755910b787b7aa 100644 (file)
@@ -65,7 +65,7 @@ public class DeviceLinkInfo {
     public URI createDeviceLinkUri() {
         final var deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
         try {
-            return new URI("tsdevice:/?uuid="
+            return new URI("sgnl://linkdevice?uuid="
                     + URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
                     + "&pub_key="
                     + URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8));
index 943b5e7580526dfbe84dc147ddb18e1c6513acb1..93afd9a691d59ca2970fde2b021bd37c434d5fde 100644 (file)
@@ -8,13 +8,12 @@ import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.api.SendGroupMessageResults;
 import org.asamk.signal.manager.api.SendMessageResults;
 import org.asamk.signal.manager.api.TypingAction;
+import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.config.ServiceConfig;
 import org.asamk.signal.manager.config.ServiceEnvironment;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
-import org.asamk.signal.manager.groups.GroupLinkState;
 import org.asamk.signal.manager.groups.GroupNotFoundException;
-import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.groups.LastGroupAdminException;
 import org.asamk.signal.manager.groups.NotAGroupMemberException;
@@ -23,7 +22,6 @@ import org.asamk.signal.manager.storage.identities.TrustNewIdentity;
 import org.asamk.signal.manager.storage.recipients.Contact;
 import org.asamk.signal.manager.storage.recipients.Profile;
 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
-import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
@@ -140,20 +138,7 @@ public interface Manager extends Closeable {
     ) throws IOException, AttachmentInvalidException;
 
     SendGroupMessageResults updateGroup(
-            GroupId groupId,
-            String name,
-            String description,
-            Set<RecipientIdentifier.Single> members,
-            Set<RecipientIdentifier.Single> removeMembers,
-            Set<RecipientIdentifier.Single> admins,
-            Set<RecipientIdentifier.Single> removeAdmins,
-            boolean resetGroupLink,
-            GroupLinkState groupLinkState,
-            GroupPermission addMemberPermission,
-            GroupPermission editDetailsPermission,
-            File avatarFile,
-            Integer expirationTimer,
-            Boolean isAnnouncementGroup
+            final GroupId groupId, final UpdateGroup updateGroup
     ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException;
 
     Pair<GroupId, SendGroupMessageResults> joinGroup(
@@ -200,7 +185,7 @@ public interface Manager extends Closeable {
 
     void setGroupBlocked(
             GroupId groupId, boolean blocked
-    ) throws GroupNotFoundException, IOException;
+    ) throws GroupNotFoundException, IOException, NotMasterDeviceException;
 
     void setExpirationTimer(
             RecipientIdentifier.Single recipient, int messageExpirationTimer
@@ -244,8 +229,6 @@ public interface Manager extends Closeable {
 
     boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient);
 
-    String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey);
-
     SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address);
 
     @Override
index b8414329d77ccc14b7037a8ca7ef658d3faa4541..a4121e29279e44e5befe13dd47e577647d34126e 100644 (file)
@@ -25,13 +25,12 @@ import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.api.SendGroupMessageResults;
 import org.asamk.signal.manager.api.SendMessageResults;
 import org.asamk.signal.manager.api.TypingAction;
+import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.config.ServiceConfig;
 import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
-import org.asamk.signal.manager.groups.GroupLinkState;
 import org.asamk.signal.manager.groups.GroupNotFoundException;
-import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.groups.LastGroupAdminException;
 import org.asamk.signal.manager.groups.NotAGroupMemberException;
@@ -39,6 +38,7 @@ import org.asamk.signal.manager.helper.AttachmentHelper;
 import org.asamk.signal.manager.helper.ContactHelper;
 import org.asamk.signal.manager.helper.GroupHelper;
 import org.asamk.signal.manager.helper.GroupV2Helper;
+import org.asamk.signal.manager.helper.IdentityHelper;
 import org.asamk.signal.manager.helper.IncomingMessageHandler;
 import org.asamk.signal.manager.helper.PinHelper;
 import org.asamk.signal.manager.helper.PreKeyHelper;
@@ -60,15 +60,10 @@ import org.asamk.signal.manager.storage.stickers.Sticker;
 import org.asamk.signal.manager.storage.stickers.StickerPackId;
 import org.asamk.signal.manager.util.KeyUtils;
 import org.asamk.signal.manager.util.StickerUtils;
-import org.asamk.signal.manager.util.Utils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.ecc.ECPublicKey;
-import org.whispersystems.libsignal.fingerprint.Fingerprint;
-import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
-import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
 import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.SignalSessionLock;
@@ -99,9 +94,7 @@ import java.net.URISyntaxException;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.SignatureException;
-import java.util.Arrays;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -113,7 +106,6 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import static org.asamk.signal.manager.config.ServiceConfig.capabilities;
@@ -139,6 +131,7 @@ public class ManagerImpl implements Manager {
     private final ContactHelper contactHelper;
     private final IncomingMessageHandler incomingMessageHandler;
     private final PreKeyHelper preKeyHelper;
+    private final IdentityHelper identityHelper;
 
     private final Context context;
     private boolean hasCaughtUpWithOldMessages = false;
@@ -177,14 +170,13 @@ public class ManagerImpl implements Manager {
 
         this.attachmentHelper = new AttachmentHelper(dependencies, attachmentStore);
         this.pinHelper = new PinHelper(dependencies.getKeyBackupService());
-        final var unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey,
-                account.getProfileStore()::getProfileKey,
-                this::getRecipientProfile,
-                this::getSenderCertificate);
+        final var unidentifiedAccessHelper = new UnidentifiedAccessHelper(account,
+                dependencies,
+                account::getProfileKey,
+                this::getRecipientProfile);
         this.profileHelper = new ProfileHelper(account,
                 dependencies,
                 avatarStore,
-                account.getProfileStore()::getProfileKey,
                 unidentifiedAccessHelper::getAccessFor,
                 this::resolveSignalServiceAddress);
         final GroupV2Helper groupV2Helper = new GroupV2Helper(profileHelper::getRecipientProfileKeyCredential,
@@ -240,6 +232,11 @@ public class ManagerImpl implements Manager {
                 syncHelper,
                 this::getRecipientProfile,
                 jobExecutor);
+        this.identityHelper = new IdentityHelper(account,
+                dependencies,
+                this::resolveSignalServiceAddress,
+                syncHelper,
+                profileHelper);
     }
 
     @Override
@@ -518,9 +515,12 @@ public class ManagerImpl implements Manager {
                         .map(account.getRecipientStore()::resolveRecipientAddress)
                         .collect(Collectors.toSet()),
                 groupInfo.isBlocked(),
-                groupInfo.getMessageExpirationTime(),
-                groupInfo.isAnnouncementGroup(),
-                groupInfo.isMember(account.getSelfRecipientId()));
+                groupInfo.getMessageExpirationTimer(),
+                groupInfo.getPermissionAddMember(),
+                groupInfo.getPermissionEditDetails(),
+                groupInfo.getPermissionSendMessage(),
+                groupInfo.isMember(account.getSelfRecipientId()),
+                groupInfo.isAdmin(account.getSelfRecipientId()));
     }
 
     @Override
@@ -545,35 +545,22 @@ public class ManagerImpl implements Manager {
 
     @Override
     public SendGroupMessageResults updateGroup(
-            GroupId groupId,
-            String name,
-            String description,
-            Set<RecipientIdentifier.Single> members,
-            Set<RecipientIdentifier.Single> removeMembers,
-            Set<RecipientIdentifier.Single> admins,
-            Set<RecipientIdentifier.Single> removeAdmins,
-            boolean resetGroupLink,
-            GroupLinkState groupLinkState,
-            GroupPermission addMemberPermission,
-            GroupPermission editDetailsPermission,
-            File avatarFile,
-            Integer expirationTimer,
-            Boolean isAnnouncementGroup
+            final GroupId groupId, final UpdateGroup updateGroup
     ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
         return groupHelper.updateGroup(groupId,
-                name,
-                description,
-                members == null ? null : resolveRecipients(members),
-                removeMembers == null ? null : resolveRecipients(removeMembers),
-                admins == null ? null : resolveRecipients(admins),
-                removeAdmins == null ? null : resolveRecipients(removeAdmins),
-                resetGroupLink,
-                groupLinkState,
-                addMemberPermission,
-                editDetailsPermission,
-                avatarFile,
-                expirationTimer,
-                isAnnouncementGroup);
+                updateGroup.getName(),
+                updateGroup.getDescription(),
+                updateGroup.getMembers() == null ? null : resolveRecipients(updateGroup.getMembers()),
+                updateGroup.getRemoveMembers() == null ? null : resolveRecipients(updateGroup.getRemoveMembers()),
+                updateGroup.getAdmins() == null ? null : resolveRecipients(updateGroup.getAdmins()),
+                updateGroup.getRemoveAdmins() == null ? null : resolveRecipients(updateGroup.getRemoveAdmins()),
+                updateGroup.isResetGroupLink(),
+                updateGroup.getGroupLinkState(),
+                updateGroup.getAddMemberPermission(),
+                updateGroup.getEditDetailsPermission(),
+                updateGroup.getAvatarFile(),
+                updateGroup.getExpirationTimer(),
+                updateGroup.getIsAnnouncementGroup());
     }
 
     @Override
@@ -739,7 +726,10 @@ public class ManagerImpl implements Manager {
     @Override
     public void setGroupBlocked(
             final GroupId groupId, final boolean blocked
-    ) throws GroupNotFoundException, IOException {
+    ) throws GroupNotFoundException, IOException, NotMasterDeviceException {
+        if (!account.isMasterDevice()) {
+            throw new NotMasterDeviceException();
+        }
         groupHelper.setGroupBlocked(groupId, blocked);
         // TODO cycle our profile key
         syncHelper.sendBlockedList();
@@ -806,22 +796,6 @@ public class ManagerImpl implements Manager {
         }
     }
 
-    private byte[] getSenderCertificate() {
-        byte[] certificate;
-        try {
-            if (account.isPhoneNumberShared()) {
-                certificate = dependencies.getAccountManager().getSenderCertificate();
-            } else {
-                certificate = dependencies.getAccountManager().getSenderCertificateForPhoneNumberPrivacy();
-            }
-        } catch (IOException e) {
-            logger.warn("Failed to get sender certificate, ignoring: {}", e.getMessage());
-            return null;
-        }
-        // TODO cache for a day
-        return certificate;
-    }
-
     private RecipientId refreshRegisteredUser(RecipientId recipientId) throws IOException {
         final var address = resolveSignalServiceAddress(recipientId);
         if (!address.getNumber().isPresent()) {
@@ -1080,7 +1054,7 @@ public class ManagerImpl implements Manager {
         return toGroup(groupHelper.getGroup(groupId));
     }
 
-    public GroupInfo getGroupInfo(GroupId groupId) {
+    private GroupInfo getGroupInfo(GroupId groupId) {
         return groupHelper.getGroup(groupId);
     }
 
@@ -1101,8 +1075,9 @@ public class ManagerImpl implements Manager {
         final var address = account.getRecipientStore().resolveRecipientAddress(identityInfo.getRecipientId());
         return new Identity(address,
                 identityInfo.getIdentityKey(),
-                computeSafetyNumber(address.toSignalServiceAddress(), identityInfo.getIdentityKey()),
-                computeSafetyNumberForScanning(address.toSignalServiceAddress(), identityInfo.getIdentityKey()),
+                identityHelper.computeSafetyNumber(identityInfo.getRecipientId(), identityInfo.getIdentityKey()),
+                identityHelper.computeSafetyNumberForScanning(identityInfo.getRecipientId(),
+                        identityInfo.getIdentityKey()).getSerialized(),
                 identityInfo.getTrustLevel(),
                 identityInfo.getDateAdded());
     }
@@ -1132,9 +1107,7 @@ public class ManagerImpl implements Manager {
         } catch (UnregisteredUserException e) {
             return false;
         }
-        return trustIdentity(recipientId,
-                identityKey -> Arrays.equals(identityKey.serialize(), fingerprint),
-                TrustLevel.TRUSTED_VERIFIED);
+        return identityHelper.trustIdentityVerified(recipientId, fingerprint);
     }
 
     /**
@@ -1151,10 +1124,7 @@ public class ManagerImpl implements Manager {
         } catch (UnregisteredUserException e) {
             return false;
         }
-        var address = resolveSignalServiceAddress(recipientId);
-        return trustIdentity(recipientId,
-                identityKey -> safetyNumber.equals(computeSafetyNumber(address, identityKey)),
-                TrustLevel.TRUSTED_VERIFIED);
+        return identityHelper.trustIdentityVerifiedSafetyNumber(recipientId, safetyNumber);
     }
 
     /**
@@ -1171,15 +1141,7 @@ public class ManagerImpl implements Manager {
         } catch (UnregisteredUserException e) {
             return false;
         }
-        var address = resolveSignalServiceAddress(recipientId);
-        return trustIdentity(recipientId, identityKey -> {
-            final var fingerprint = computeSafetyNumberFingerprint(address, identityKey);
-            try {
-                return fingerprint != null && fingerprint.getScannableFingerprint().compareTo(safetyNumber);
-            } catch (FingerprintVersionMismatchException | FingerprintParsingException e) {
-                return false;
-            }
-        }, TrustLevel.TRUSTED_VERIFIED);
+        return identityHelper.trustIdentityVerifiedSafetyNumber(recipientId, safetyNumber);
     }
 
     /**
@@ -1195,66 +1157,13 @@ public class ManagerImpl implements Manager {
         } catch (UnregisteredUserException e) {
             return false;
         }
-        return trustIdentity(recipientId, identityKey -> true, TrustLevel.TRUSTED_UNVERIFIED);
-    }
-
-    private boolean trustIdentity(
-            RecipientId recipientId, Function<IdentityKey, Boolean> verifier, TrustLevel trustLevel
-    ) {
-        var identity = account.getIdentityKeyStore().getIdentity(recipientId);
-        if (identity == null) {
-            return false;
-        }
-
-        if (!verifier.apply(identity.getIdentityKey())) {
-            return false;
-        }
-
-        account.getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), trustLevel);
-        try {
-            var address = resolveSignalServiceAddress(recipientId);
-            syncHelper.sendVerifiedMessage(address, identity.getIdentityKey(), trustLevel);
-        } catch (IOException e) {
-            logger.warn("Failed to send verification sync message: {}", e.getMessage());
-        }
-
-        return true;
+        return identityHelper.trustIdentityAllKeys(recipientId);
     }
 
     private void handleIdentityFailure(
             final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure
     ) {
-        final var identityKey = identityFailure.getIdentityKey();
-        if (identityKey != null) {
-            final var newIdentity = account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date());
-            if (newIdentity) {
-                account.getSessionStore().archiveSessions(recipientId);
-            }
-        } else {
-            // Retrieve profile to get the current identity key from the server
-            profileHelper.refreshRecipientProfile(recipientId);
-        }
-    }
-
-    @Override
-    public String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) {
-        final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey);
-        return fingerprint == null ? null : fingerprint.getDisplayableFingerprint().getDisplayText();
-    }
-
-    private byte[] computeSafetyNumberForScanning(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) {
-        final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey);
-        return fingerprint == null ? null : fingerprint.getScannableFingerprint().getSerialized();
-    }
-
-    private Fingerprint computeSafetyNumberFingerprint(
-            final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey
-    ) {
-        return Utils.computeSafetyNumber(capabilities.isUuid(),
-                account.getSelfAddress(),
-                account.getIdentityKeyPair().getPublicKey(),
-                theirAddress,
-                theirIdentityKey);
+        this.identityHelper.handleIdentityFailure(recipientId, identityFailure);
     }
 
     @Override
@@ -1329,5 +1238,4 @@ public class ManagerImpl implements Manager {
         }
         account = null;
     }
-
 }
index 24a673ff867508e68046ab386bd9495fa85abd23..556e227d422341caba88b4011dc2a7f6af27611c 100644 (file)
@@ -101,6 +101,7 @@ public final class SignalWebSocketHealthMonitor implements HealthMonitor {
             if (healthState.mismatchErrorTracker.addSample(System.currentTimeMillis())) {
                 logger.warn("Received too many mismatch device errors, forcing new websockets.");
                 signalWebSocket.forceNewWebSockets();
+                signalWebSocket.connect();
             }
         }
     }
@@ -146,6 +147,7 @@ public final class SignalWebSocketHealthMonitor implements HealthMonitor {
                                     + " needed by: "
                                     + keepAliveRequiredSinceTime);
                             signalWebSocket.forceNewWebSockets();
+                            signalWebSocket.connect();
                         } else {
                             signalWebSocket.sendKeepAlive();
                         }
index 650e10b6eeffcde5eee2fe45001fe4eba66743e7..4787ef950fb98c856bf7b718b904ebbb93adf25c 100644 (file)
@@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
 
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
+import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
 
 import java.util.Set;
@@ -17,9 +18,13 @@ public class Group {
     private final Set<RecipientAddress> requestingMembers;
     private final Set<RecipientAddress> adminMembers;
     private final boolean isBlocked;
-    private final int messageExpirationTime;
-    private final boolean isAnnouncementGroup;
+    private final int messageExpirationTimer;
+
+    private final GroupPermission permissionAddMember;
+    private final GroupPermission permissionEditDetails;
+    private final GroupPermission permissionSendMessage;
     private final boolean isMember;
+    private final boolean isAdmin;
 
     public Group(
             final GroupId groupId,
@@ -31,9 +36,12 @@ public class Group {
             final Set<RecipientAddress> requestingMembers,
             final Set<RecipientAddress> adminMembers,
             final boolean isBlocked,
-            final int messageExpirationTime,
-            final boolean isAnnouncementGroup,
-            final boolean isMember
+            final int messageExpirationTimer,
+            final GroupPermission permissionAddMember,
+            final GroupPermission permissionEditDetails,
+            final GroupPermission permissionSendMessage,
+            final boolean isMember,
+            final boolean isAdmin
     ) {
         this.groupId = groupId;
         this.title = title;
@@ -44,9 +52,12 @@ public class Group {
         this.requestingMembers = requestingMembers;
         this.adminMembers = adminMembers;
         this.isBlocked = isBlocked;
-        this.messageExpirationTime = messageExpirationTime;
-        this.isAnnouncementGroup = isAnnouncementGroup;
+        this.messageExpirationTimer = messageExpirationTimer;
+        this.permissionAddMember = permissionAddMember;
+        this.permissionEditDetails = permissionEditDetails;
+        this.permissionSendMessage = permissionSendMessage;
         this.isMember = isMember;
+        this.isAdmin = isAdmin;
     }
 
     public GroupId getGroupId() {
@@ -85,15 +96,27 @@ public class Group {
         return isBlocked;
     }
 
-    public int getMessageExpirationTime() {
-        return messageExpirationTime;
+    public int getMessageExpirationTimer() {
+        return messageExpirationTimer;
+    }
+
+    public GroupPermission getPermissionAddMember() {
+        return permissionAddMember;
     }
 
-    public boolean isAnnouncementGroup() {
-        return isAnnouncementGroup;
+    public GroupPermission getPermissionEditDetails() {
+        return permissionEditDetails;
+    }
+
+    public GroupPermission getPermissionSendMessage() {
+        return permissionSendMessage;
     }
 
     public boolean isMember() {
         return isMember;
     }
+
+    public boolean isAdmin() {
+        return isAdmin;
+    }
 }
diff --git a/lib/src/main/java/org/asamk/signal/manager/api/UpdateGroup.java b/lib/src/main/java/org/asamk/signal/manager/api/UpdateGroup.java
new file mode 100644 (file)
index 0000000..b5877ae
--- /dev/null
@@ -0,0 +1,203 @@
+package org.asamk.signal.manager.api;
+
+import org.asamk.signal.manager.groups.GroupLinkState;
+import org.asamk.signal.manager.groups.GroupPermission;
+
+import java.io.File;
+import java.util.Set;
+
+public class UpdateGroup {
+
+    private final String name;
+    private final String description;
+    private final Set<RecipientIdentifier.Single> members;
+    private final Set<RecipientIdentifier.Single> removeMembers;
+    private final Set<RecipientIdentifier.Single> admins;
+    private final Set<RecipientIdentifier.Single> removeAdmins;
+    private final boolean resetGroupLink;
+    private final GroupLinkState groupLinkState;
+    private final GroupPermission addMemberPermission;
+    private final GroupPermission editDetailsPermission;
+    private final File avatarFile;
+    private final Integer expirationTimer;
+    private final Boolean isAnnouncementGroup;
+
+    private UpdateGroup(final Builder builder) {
+        name = builder.name;
+        description = builder.description;
+        members = builder.members;
+        removeMembers = builder.removeMembers;
+        admins = builder.admins;
+        removeAdmins = builder.removeAdmins;
+        resetGroupLink = builder.resetGroupLink;
+        groupLinkState = builder.groupLinkState;
+        addMemberPermission = builder.addMemberPermission;
+        editDetailsPermission = builder.editDetailsPermission;
+        avatarFile = builder.avatarFile;
+        expirationTimer = builder.expirationTimer;
+        isAnnouncementGroup = builder.isAnnouncementGroup;
+    }
+
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+
+    public static Builder newBuilder(final UpdateGroup copy) {
+        Builder builder = new Builder();
+        builder.name = copy.getName();
+        builder.description = copy.getDescription();
+        builder.members = copy.getMembers();
+        builder.removeMembers = copy.getRemoveMembers();
+        builder.admins = copy.getAdmins();
+        builder.removeAdmins = copy.getRemoveAdmins();
+        builder.resetGroupLink = copy.isResetGroupLink();
+        builder.groupLinkState = copy.getGroupLinkState();
+        builder.addMemberPermission = copy.getAddMemberPermission();
+        builder.editDetailsPermission = copy.getEditDetailsPermission();
+        builder.avatarFile = copy.getAvatarFile();
+        builder.expirationTimer = copy.getExpirationTimer();
+        builder.isAnnouncementGroup = copy.getIsAnnouncementGroup();
+        return builder;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public Set<RecipientIdentifier.Single> getMembers() {
+        return members;
+    }
+
+    public Set<RecipientIdentifier.Single> getRemoveMembers() {
+        return removeMembers;
+    }
+
+    public Set<RecipientIdentifier.Single> getAdmins() {
+        return admins;
+    }
+
+    public Set<RecipientIdentifier.Single> getRemoveAdmins() {
+        return removeAdmins;
+    }
+
+    public boolean isResetGroupLink() {
+        return resetGroupLink;
+    }
+
+    public GroupLinkState getGroupLinkState() {
+        return groupLinkState;
+    }
+
+    public GroupPermission getAddMemberPermission() {
+        return addMemberPermission;
+    }
+
+    public GroupPermission getEditDetailsPermission() {
+        return editDetailsPermission;
+    }
+
+    public File getAvatarFile() {
+        return avatarFile;
+    }
+
+    public Integer getExpirationTimer() {
+        return expirationTimer;
+    }
+
+    public Boolean getIsAnnouncementGroup() {
+        return isAnnouncementGroup;
+    }
+
+    public static final class Builder {
+
+        private String name;
+        private String description;
+        private Set<RecipientIdentifier.Single> members;
+        private Set<RecipientIdentifier.Single> removeMembers;
+        private Set<RecipientIdentifier.Single> admins;
+        private Set<RecipientIdentifier.Single> removeAdmins;
+        private boolean resetGroupLink;
+        private GroupLinkState groupLinkState;
+        private GroupPermission addMemberPermission;
+        private GroupPermission editDetailsPermission;
+        private File avatarFile;
+        private Integer expirationTimer;
+        private Boolean isAnnouncementGroup;
+
+        private Builder() {
+        }
+
+        public Builder withName(final String val) {
+            name = val;
+            return this;
+        }
+
+        public Builder withDescription(final String val) {
+            description = val;
+            return this;
+        }
+
+        public Builder withMembers(final Set<RecipientIdentifier.Single> val) {
+            members = val;
+            return this;
+        }
+
+        public Builder withRemoveMembers(final Set<RecipientIdentifier.Single> val) {
+            removeMembers = val;
+            return this;
+        }
+
+        public Builder withAdmins(final Set<RecipientIdentifier.Single> val) {
+            admins = val;
+            return this;
+        }
+
+        public Builder withRemoveAdmins(final Set<RecipientIdentifier.Single> val) {
+            removeAdmins = val;
+            return this;
+        }
+
+        public Builder withResetGroupLink(final boolean val) {
+            resetGroupLink = val;
+            return this;
+        }
+
+        public Builder withGroupLinkState(final GroupLinkState val) {
+            groupLinkState = val;
+            return this;
+        }
+
+        public Builder withAddMemberPermission(final GroupPermission val) {
+            addMemberPermission = val;
+            return this;
+        }
+
+        public Builder withEditDetailsPermission(final GroupPermission val) {
+            editDetailsPermission = val;
+            return this;
+        }
+
+        public Builder withAvatarFile(final File val) {
+            avatarFile = val;
+            return this;
+        }
+
+        public Builder withExpirationTimer(final Integer val) {
+            expirationTimer = val;
+            return this;
+        }
+
+        public Builder withIsAnnouncementGroup(final Boolean val) {
+            isAnnouncementGroup = val;
+            return this;
+        }
+
+        public UpdateGroup build() {
+            return new UpdateGroup(this);
+        }
+    }
+}
index 62f4f11134b7169f747c24a522129c08e708c570..ee2e94166ccbb55b23c7cb33402038d7f22924cd 100644 (file)
@@ -639,7 +639,7 @@ public class GroupHelper {
 
         return SignalServiceDataMessage.newBuilder()
                 .asGroupMessage(group.build())
-                .withExpiration(g.getMessageExpirationTime());
+                .withExpiration(g.getMessageExpirationTimer());
     }
 
     private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
@@ -648,7 +648,7 @@ public class GroupHelper {
                 .withSignedGroupChange(signedGroupChange);
         return SignalServiceDataMessage.newBuilder()
                 .asGroupMessage(group.build())
-                .withExpiration(g.getMessageExpirationTime());
+                .withExpiration(g.getMessageExpirationTimer());
     }
 
     private SendGroupMessageResults sendUpdateGroupV2Message(
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IdentityHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/IdentityHelper.java
new file mode 100644 (file)
index 0000000..531870d
--- /dev/null
@@ -0,0 +1,135 @@
+package org.asamk.signal.manager.helper;
+
+import org.asamk.signal.manager.SignalDependencies;
+import org.asamk.signal.manager.TrustLevel;
+import org.asamk.signal.manager.storage.SignalAccount;
+import org.asamk.signal.manager.storage.recipients.RecipientId;
+import org.asamk.signal.manager.util.Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.libsignal.IdentityKey;
+import org.whispersystems.libsignal.fingerprint.Fingerprint;
+import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
+import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
+import org.whispersystems.libsignal.fingerprint.ScannableFingerprint;
+import org.whispersystems.signalservice.api.messages.SendMessageResult;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.function.Function;
+
+import static org.asamk.signal.manager.config.ServiceConfig.capabilities;
+
+public class IdentityHelper {
+
+    private final static Logger logger = LoggerFactory.getLogger(IdentityHelper.class);
+
+    private final SignalAccount account;
+    private final SignalDependencies dependencies;
+    private final SignalServiceAddressResolver addressResolver;
+    private final SyncHelper syncHelper;
+    private final ProfileHelper profileHelper;
+
+    public IdentityHelper(
+            final SignalAccount account,
+            final SignalDependencies dependencies,
+            final SignalServiceAddressResolver addressResolver,
+            final SyncHelper syncHelper,
+            final ProfileHelper profileHelper
+    ) {
+        this.account = account;
+        this.dependencies = dependencies;
+        this.addressResolver = addressResolver;
+        this.syncHelper = syncHelper;
+        this.profileHelper = profileHelper;
+    }
+
+    public boolean trustIdentityVerified(RecipientId recipientId, byte[] fingerprint) {
+        return trustIdentity(recipientId,
+                identityKey -> Arrays.equals(identityKey.serialize(), fingerprint),
+                TrustLevel.TRUSTED_VERIFIED);
+    }
+
+    public boolean trustIdentityVerifiedSafetyNumber(RecipientId recipientId, String safetyNumber) {
+        return trustIdentity(recipientId,
+                identityKey -> safetyNumber.equals(computeSafetyNumber(recipientId, identityKey)),
+                TrustLevel.TRUSTED_VERIFIED);
+    }
+
+    public boolean trustIdentityVerifiedSafetyNumber(RecipientId recipientId, byte[] safetyNumber) {
+        return trustIdentity(recipientId, identityKey -> {
+            final var fingerprint = computeSafetyNumberForScanning(recipientId, identityKey);
+            try {
+                return fingerprint != null && fingerprint.compareTo(safetyNumber);
+            } catch (FingerprintVersionMismatchException | FingerprintParsingException e) {
+                return false;
+            }
+        }, TrustLevel.TRUSTED_VERIFIED);
+    }
+
+    public boolean trustIdentityAllKeys(RecipientId recipientId) {
+        return trustIdentity(recipientId, identityKey -> true, TrustLevel.TRUSTED_UNVERIFIED);
+    }
+
+    public String computeSafetyNumber(RecipientId recipientId, IdentityKey theirIdentityKey) {
+        var address = addressResolver.resolveSignalServiceAddress(recipientId);
+        final Fingerprint fingerprint = computeSafetyNumberFingerprint(address, theirIdentityKey);
+        return fingerprint == null ? null : fingerprint.getDisplayableFingerprint().getDisplayText();
+    }
+
+    public ScannableFingerprint computeSafetyNumberForScanning(RecipientId recipientId, IdentityKey theirIdentityKey) {
+        var address = addressResolver.resolveSignalServiceAddress(recipientId);
+        final Fingerprint fingerprint = computeSafetyNumberFingerprint(address, theirIdentityKey);
+        return fingerprint == null ? null : fingerprint.getScannableFingerprint();
+    }
+
+    private Fingerprint computeSafetyNumberFingerprint(
+            final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey
+    ) {
+        return Utils.computeSafetyNumber(capabilities.isUuid(),
+                account.getSelfAddress(),
+                account.getIdentityKeyPair().getPublicKey(),
+                theirAddress,
+                theirIdentityKey);
+    }
+
+    private boolean trustIdentity(
+            RecipientId recipientId, Function<IdentityKey, Boolean> verifier, TrustLevel trustLevel
+    ) {
+        var identity = account.getIdentityKeyStore().getIdentity(recipientId);
+        if (identity == null) {
+            return false;
+        }
+
+        if (!verifier.apply(identity.getIdentityKey())) {
+            return false;
+        }
+
+        account.getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), trustLevel);
+        try {
+            var address = addressResolver.resolveSignalServiceAddress(recipientId);
+            syncHelper.sendVerifiedMessage(address, identity.getIdentityKey(), trustLevel);
+        } catch (IOException e) {
+            logger.warn("Failed to send verification sync message: {}", e.getMessage());
+        }
+
+        return true;
+    }
+
+    public void handleIdentityFailure(
+            final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure
+    ) {
+        final var identityKey = identityFailure.getIdentityKey();
+        if (identityKey != null) {
+            final var newIdentity = account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date());
+            if (newIdentity) {
+                account.getSessionStore().archiveSessions(recipientId);
+            }
+        } else {
+            // Retrieve profile to get the current identity key from the server
+            profileHelper.refreshRecipientProfile(recipientId);
+        }
+    }
+}
index e24d41fad91464144f11f8e5ed67ad6e565c4ecc..7a492d9f98dba0291ef7ea67f28d0ed69af999cf 100644 (file)
@@ -45,7 +45,6 @@ public final class ProfileHelper {
     private final SignalAccount account;
     private final SignalDependencies dependencies;
     private final AvatarStore avatarStore;
-    private final ProfileKeyProvider profileKeyProvider;
     private final UnidentifiedAccessProvider unidentifiedAccessProvider;
     private final SignalServiceAddressResolver addressResolver;
 
@@ -53,14 +52,12 @@ public final class ProfileHelper {
             final SignalAccount account,
             final SignalDependencies dependencies,
             final AvatarStore avatarStore,
-            final ProfileKeyProvider profileKeyProvider,
             final UnidentifiedAccessProvider unidentifiedAccessProvider,
             final SignalServiceAddressResolver addressResolver
     ) {
         this.account = account;
         this.dependencies = dependencies;
         this.avatarStore = avatarStore;
-        this.profileKeyProvider = profileKeyProvider;
         this.unidentifiedAccessProvider = unidentifiedAccessProvider;
         this.addressResolver = addressResolver;
     }
@@ -296,7 +293,7 @@ public final class ProfileHelper {
             RecipientId recipientId, SignalServiceProfile.RequestType requestType
     ) {
         var unidentifiedAccess = getUnidentifiedAccess(recipientId);
-        var profileKey = Optional.fromNullable(profileKeyProvider.getProfileKey(recipientId));
+        var profileKey = Optional.fromNullable(account.getProfileStore().getProfileKey(recipientId));
 
         final var address = addressResolver.resolveSignalServiceAddress(recipientId);
         return retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileKeyProvider.java
deleted file mode 100644 (file)
index b98d674..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.asamk.signal.manager.helper;
-
-import org.asamk.signal.manager.storage.recipients.RecipientId;
-import org.signal.zkgroup.profiles.ProfileKey;
-
-public interface ProfileKeyProvider {
-
-    ProfileKey getProfileKey(RecipientId address);
-}
index c0953f1fa27be3deb94f0a95fb3a13cb32bb4670..6c0fb2e9cbde4d3974679f00e7370d0317f6ab39 100644 (file)
@@ -100,7 +100,7 @@ public class SendHelper {
             final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g
     ) throws IOException, GroupSendingNotAllowedException {
         GroupUtils.setGroupContext(messageBuilder, g);
-        messageBuilder.withExpiration(g.getMessageExpirationTime());
+        messageBuilder.withExpiration(g.getMessageExpirationTimer());
 
         final var message = messageBuilder.build();
         final var recipients = g.getMembersWithout(account.getSelfRecipientId());
index 87e23c1b33aea4782536d3be4840f6402afb2fba..661a7f96c0ed3f705cd97405b75a5a1ea2c2d73e 100644 (file)
@@ -1,11 +1,16 @@
 package org.asamk.signal.manager.helper;
 
+import org.asamk.signal.manager.SignalDependencies;
+import org.asamk.signal.manager.storage.SignalAccount;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
 
+import java.io.IOException;
 import java.util.List;
 import java.util.stream.Collectors;
 
@@ -13,24 +18,39 @@ import static org.whispersystems.signalservice.internal.util.Util.getSecretBytes
 
 public class UnidentifiedAccessHelper {
 
-    private final SelfProfileKeyProvider selfProfileKeyProvider;
-
-    private final ProfileKeyProvider profileKeyProvider;
+    private final static Logger logger = LoggerFactory.getLogger(UnidentifiedAccessHelper.class);
 
+    private final SignalAccount account;
+    private final SignalDependencies dependencies;
+    private final SelfProfileKeyProvider selfProfileKeyProvider;
     private final ProfileProvider profileProvider;
 
-    private final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider;
-
     public UnidentifiedAccessHelper(
+            final SignalAccount account,
+            final SignalDependencies dependencies,
             final SelfProfileKeyProvider selfProfileKeyProvider,
-            final ProfileKeyProvider profileKeyProvider,
-            final ProfileProvider profileProvider,
-            final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider
+            final ProfileProvider profileProvider
     ) {
+        this.account = account;
+        this.dependencies = dependencies;
         this.selfProfileKeyProvider = selfProfileKeyProvider;
-        this.profileKeyProvider = profileKeyProvider;
         this.profileProvider = profileProvider;
-        this.senderCertificateProvider = senderCertificateProvider;
+    }
+
+    private byte[] getSenderCertificate() {
+        byte[] certificate;
+        try {
+            if (account.isPhoneNumberShared()) {
+                certificate = dependencies.getAccountManager().getSenderCertificate();
+            } else {
+                certificate = dependencies.getAccountManager().getSenderCertificateForPhoneNumberPrivacy();
+            }
+        } catch (IOException e) {
+            logger.warn("Failed to get sender certificate, ignoring: {}", e.getMessage());
+            return null;
+        }
+        // TODO cache for a day
+        return certificate;
     }
 
     private byte[] getSelfUnidentifiedAccessKey() {
@@ -45,7 +65,7 @@ public class UnidentifiedAccessHelper {
 
         switch (targetProfile.getUnidentifiedAccessMode()) {
             case ENABLED:
-                var theirProfileKey = profileKeyProvider.getProfileKey(recipient);
+                var theirProfileKey = account.getProfileStore().getProfileKey(recipient);
                 if (theirProfileKey == null) {
                     return null;
                 }
@@ -60,7 +80,7 @@ public class UnidentifiedAccessHelper {
 
     public Optional<UnidentifiedAccessPair> getAccessForSync() {
         var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
-        var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
+        var selfUnidentifiedAccessCertificate = getSenderCertificate();
 
         if (selfUnidentifiedAccessKey == null || selfUnidentifiedAccessCertificate == null) {
             return Optional.absent();
@@ -82,7 +102,7 @@ public class UnidentifiedAccessHelper {
     public Optional<UnidentifiedAccessPair> getAccessFor(RecipientId recipient) {
         var recipientUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
         var selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
-        var selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
+        var selfUnidentifiedAccessCertificate = getSenderCertificate();
 
         if (recipientUnidentifiedAccessKey == null
                 || selfUnidentifiedAccessKey == null
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java b/lib/src/main/java/org/asamk/signal/manager/helper/UnidentifiedAccessSenderCertificateProvider.java
deleted file mode 100644 (file)
index b059734..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.asamk.signal.manager.helper;
-
-public interface UnidentifiedAccessSenderCertificateProvider {
-
-    byte[] getSenderCertificate();
-}
index 60efc84b2df4baf54efef4abd66317f10d1a1481..b4f4e63a6cba5fd9a3522667bae29c655f281131 100644 (file)
@@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.groups;
 
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
+import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 
 import java.util.Set;
@@ -38,10 +39,16 @@ public abstract class GroupInfo {
 
     public abstract void setBlocked(boolean blocked);
 
-    public abstract int getMessageExpirationTime();
+    public abstract int getMessageExpirationTimer();
 
     public abstract boolean isAnnouncementGroup();
 
+    public abstract GroupPermission getPermissionAddMember();
+
+    public abstract GroupPermission getPermissionEditDetails();
+
+    public abstract GroupPermission getPermissionSendMessage();
+
     public Set<RecipientId> getMembersWithout(RecipientId recipientId) {
         return getMembers().stream().filter(member -> !member.equals(recipientId)).collect(Collectors.toSet());
     }
index 49c9a5042b34187fa17df91705a8dae71a3211f7..dbd2dcbba873422296a4ed8f78e803cba297947f 100644 (file)
@@ -3,6 +3,7 @@ package org.asamk.signal.manager.storage.groups;
 import org.asamk.signal.manager.groups.GroupIdV1;
 import org.asamk.signal.manager.groups.GroupIdV2;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
+import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.groups.GroupUtils;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 
@@ -85,7 +86,7 @@ public class GroupInfoV1 extends GroupInfo {
     }
 
     @Override
-    public int getMessageExpirationTime() {
+    public int getMessageExpirationTimer() {
         return messageExpirationTime;
     }
 
@@ -94,6 +95,21 @@ public class GroupInfoV1 extends GroupInfo {
         return false;
     }
 
+    @Override
+    public GroupPermission getPermissionAddMember() {
+        return GroupPermission.EVERY_MEMBER;
+    }
+
+    @Override
+    public GroupPermission getPermissionEditDetails() {
+        return GroupPermission.EVERY_MEMBER;
+    }
+
+    @Override
+    public GroupPermission getPermissionSendMessage() {
+        return GroupPermission.EVERY_MEMBER;
+    }
+
     public void addMembers(Collection<RecipientId> members) {
         this.members.addAll(members);
     }
index a06b83dfa3203d064533266429c1d92dcfac91ee..34db2a2879ecb66590bb95715c51238935688daf 100644 (file)
@@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.groups;
 
 import org.asamk.signal.manager.groups.GroupIdV2;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
+import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 import org.asamk.signal.manager.storage.recipients.RecipientResolver;
 import org.signal.storageservice.protos.groups.AccessControl;
@@ -151,7 +152,7 @@ public class GroupInfoV2 extends GroupInfo {
     }
 
     @Override
-    public int getMessageExpirationTime() {
+    public int getMessageExpirationTimer() {
         return this.group != null && this.group.hasDisappearingMessagesTimer()
                 ? this.group.getDisappearingMessagesTimer().getDuration()
                 : 0;
@@ -162,6 +163,23 @@ public class GroupInfoV2 extends GroupInfo {
         return this.group != null && this.group.getIsAnnouncementGroup() == EnabledState.ENABLED;
     }
 
+    @Override
+    public GroupPermission getPermissionAddMember() {
+        final var accessControl = getAccessControl();
+        return accessControl == null ? GroupPermission.EVERY_MEMBER : toGroupPermission(accessControl.getMembers());
+    }
+
+    @Override
+    public GroupPermission getPermissionEditDetails() {
+        final var accessControl = getAccessControl();
+        return accessControl == null ? GroupPermission.EVERY_MEMBER : toGroupPermission(accessControl.getAttributes());
+    }
+
+    @Override
+    public GroupPermission getPermissionSendMessage() {
+        return isAnnouncementGroup() ? GroupPermission.ONLY_ADMINS : GroupPermission.EVERY_MEMBER;
+    }
+
     public void setPermissionDenied(final boolean permissionDenied) {
         this.permissionDenied = permissionDenied;
     }
@@ -169,4 +187,22 @@ public class GroupInfoV2 extends GroupInfo {
     public boolean isPermissionDenied() {
         return permissionDenied;
     }
+
+    private AccessControl getAccessControl() {
+        if (this.group == null || !this.group.hasAccessControl()) {
+            return null;
+        }
+
+        return this.group.getAccessControl();
+    }
+
+    private static GroupPermission toGroupPermission(final AccessControl.AccessRequired permission) {
+        switch (permission) {
+            case ADMINISTRATOR:
+                return GroupPermission.ONLY_ADMINS;
+            case MEMBER:
+            default:
+                return GroupPermission.EVERY_MEMBER;
+        }
+    }
 }
index 508a788113201f0cd196a6ae808191108579094f..1a0fbfaaa76cd495b8d8628413b38b63aae80963 100755 (executable)
@@ -29,7 +29,7 @@ method(arg1<type>, arg2<type>, ...) -> return<type>
 
 Where <type> is according to DBus specification:
 
-* <a>   : Array of ... (comma-separated listarray:)
+* <a>   : Array of ... (comma-separated list) (array:)
 * (...) : Struct (cannot be sent via `dbus-send`)
 * <b>   : Boolean (false|true) (boolean:)
 * <i>   : Signed 32-bit (int) integer (int32:)
@@ -48,9 +48,9 @@ Phone numbers always have the format +<countrycode><regional number>
 == Methods
 
 === Control methods
-These methods are available if the daemon is started anonymously (without an explicit `-u USERNAME`). 
-Requests are sent to `/org/asamk/Signal`; requests related to individual accounts are sent to 
-`/org/asamk/Signal/_441234567890` where the + dialing code is replaced by an underscore (_). 
+These methods are available if the daemon is started anonymously (without an explicit `-u USERNAME`).
+Requests are sent to `/org/asamk/Signal`; requests related to individual accounts are sent to
+`/org/asamk/Signal/_441234567890` where the + dialing code is replaced by an underscore (_).
 Only `version()` is activated in single-user mode; the rest are disabled.
 
 link() -> deviceLinkUri<s>::
@@ -58,12 +58,12 @@ link(newDeviceName<s>) -> deviceLinkUri<s>::
 * newDeviceName : Name to give new device (defaults to "cli" if no name is given)
 * deviceLinkUri : URI of newly linked device
 
-Returns a URI of the form "tsdevice:/?uuid=...". This can be piped to a QR encoder to create a display that
+Returns a URI of the form "sgnl://linkdevice/?uuid=...". This can be piped to a QR encoder to create a display that
 can be captured by a Signal smartphone client. For example:
 
 `dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client"|tr '\n' '\0'|sed 's/.*string //g'|sed 's/\"//g'|qrencode -s10 -tANSI256`
 
-Exception: Failure
+Exceptions: Failure
 
 listAccounts() -> accountList<as>::
 * accountList : Array of all attached accounts in DBus object path form
@@ -89,94 +89,243 @@ verify(number<s>, verificationCode<s>) -> <>::
 
 Command fails if PIN was set after previous registration; use verifyWithPin instead.
 
-Exception: Failure, InvalidNumber
+Exceptions: Failure, InvalidNumber
 
 verifyWithPin(number<s>, verificationCode<s>, pin<s>) -> <>::
 * number            : Phone number
 * verificationCode  : Code received from Signal after successful registration request
 * pin               : PIN you set with setPin command after verifying previous registration
 
-Exception: Failure, InvalidNumber
+Exceptions: Failure, InvalidNumber
 
 version() -> version<s>::
 * version : Version string of signal-cli
 
 Exceptions: None
 
-=== Other methods
+=== Group control methods
+The following methods listen to the recipient's object path, which is constructed as follows:
+"/org/asamk/Signal/" + DBusNumber
+* DBusNumber  : recipient's phone number, with underscore (_) replacing plus (+)
 
-updateGroup(groupId<ay>, newName<s>, members<as>, avatar<s>) -> groupId<ay>::
-* groupId  : Byte array representing the internal group identifier
-* newName  : New name of group (empty if unchanged)
-* members  : String array of new members to be invited to group
-* avatar   : Filename of avatar picture to be set for group (empty if none)
+createGroup(groupName<s>, members<as>, avatar<s>) -> groupId<ay>::
+* groupName : String representing the display name of the group
+* members   : String array of new members to be invited to group
+* avatar    : Filename of avatar picture to be set for group (empty if none)
+* groupId   : Byte array representing the internal group identifier
 
-Exceptions: AttachmentInvalid, Failure, InvalidNumber, GroupNotFound
+Exceptions: AttachmentInvalid, Failure, InvalidNumber;
 
-updateProfile(name<s>, about<s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
-updateProfile(givenName<s>, familyName<s>, about<s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
-* name        : Name for your own profile (empty if unchanged)
-* givenName   : Given name for your own profile (empty if unchanged)
-* familyName  : Family name for your own profile (empty if unchanged)
-* about       : About message for profile (empty if unchanged)
-* aboutEmoji  : Emoji for profile (empty if unchanged)
-* avatar      : Filename of avatar picture for profile (empty if unchanged)
-* remove      : Set to true if the existing avatar picture should be removed
+getGroup(groupId<ay>) -> objectPath<o>::
+* groupId    : Byte array representing the internal group identifier
+* objectPath : DBusPath for the group
+
+getGroupMembers(groupId<ay>) -> members<as>::
+* groupId   : Byte array representing the internal group identifier
+* members   : String array with the phone numbers of all active members of a group
+
+Exceptions: None, if the group name is not found an empty array is returned
+
+joinGroup(inviteURI<s>) -> <>::
+* inviteURI : String starting with https://signal.group/#
+
+Behavior of this method depends on the `requirePermission` parameter of the `enableLink` method. If permission is required, `joinGroup` adds you to the requesting members list. Permission may be granted based on the group's `PermissionAddMember` property (`ONLY_ADMINS` or `EVERY_MEMBER`). If permission is not required, `joinGroup` admits you immediately to the group.
 
 Exceptions: Failure
 
+listGroups() -> groups<a(oays)>::
+* groups          : Array of Structs(objectPath, groupId, groupName)
+** objectPath      : DBusPath
+** groupId         : Byte array representing the internal group identifier
+** groupName       : String representing the display name of the group
 
-setExpirationTimer(number<s>, expiration<i>) -> <>::
-* number     : Phone number of recipient
-* expiration : int32 for the number of seconds before messages to this recipient disappear. Set to 0 to disable expiration.
+sendGroupMessage(message<s>, attachments<as>, groupId<ay>) -> timestamp<x>::
+* message     : Text to send (can be UTF8)
+* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
+* groupId     : Byte array representing the internal group identifier
+* timestamp   : Long, can be used to identify the corresponding Signal reply
+
+Exceptions: GroupNotFound, Failure, AttachmentInvalid, InvalidGroupId
+
+sendGroupMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
+* emoji               : Unicode grapheme cluster of the emoji
+* remove              : Boolean, whether a previously sent reaction (emoji) should be removed
+* targetAuthor        : String with the phone number of the author of the message to which to react
+* targetSentTimestamp : Long representing timestamp of the message to which to react
+* groupId             : Byte array representing the internal group identifier
+* timestamp           : Long, can be used to identify the corresponding signal reply
+
+Exceptions: Failure, InvalidNumber, GroupNotFound, InvalidGroupId
+
+sendGroupRemoteDeleteMessage(targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
+* targetSentTimestamp : Long representing timestamp of the message to delete
+* groupId             : Byte array with base64 encoded group identifier
+* timestamp           : Long, can be used to identify the corresponding signal reply
+
+Exceptions: Failure, GroupNotFound, InvalidGroupId
+
+=== Group methods
+The following methods listen to the group's object path, which can be obtained from the listGroups() method and is constructed as follows:
+"/org/asamk/Signal/" + DBusNumber + "/Groups/" + DBusGroupId
+* DBusNumber  : recipient's phone number, with underscore (_) replacing plus (+)
+* DBusGroupId : groupId in base64 format, with underscore (_) replacing plus (+), equals (=), or slash (/)
+
+Groups have the following (case-sensitive) properties:
+* Id<ay> (read-only)                : Byte array representing the internal group identifier
+* Name<s>                           : Display name of the group
+* Description<s>                    : Description of the group
+* Avatar<s> (write-only)            : Filename of the avatar
+* IsBlocked<b>                      : true=member will not receive group messages; false=not blocked
+* IsMember<b> (read-only)           : always true (object path exists only for group members)
+* IsAdmin<b> (read-only)            : true=member has admin privileges; false=not admin
+* MessageExpirationTimer<i>         : int32 representing message expiration time for group
+* Members<as> (read-only)           : String array of group members' phone numbers
+* PendingMembers<as> (read-only)    : String array of pending members' phone numbers
+* RequestingMembers<as> (read-only) : String array of requesting members' phone numbers
+* Admins<as> (read-only)            : String array of admins' phone numbers
+* PermissionAddMember<s>            : String representing who has permission to add members
+** ONLY_ADMINS, EVERY_MEMBER
+* PermissionEditDetails<s>          : String representing who may edit group details
+** ONLY_ADMINS, EVERY_MEMBER
+* PermissionSendMessage<s>          : String representing who post messages to group
+** ONLY_ADMINS, EVERY_MEMBER (note that ONLY_ADMINS is equivalent to IsAnnouncementGroup)
+* GroupInviteLink<s> (read-only)    : String of the invitation link (starts with https://signal.group/#)
+
+To get a property, use (replacing `--session` with `--system` if needed):
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.Get string:org.asamk.Signal.Group string:$PROPERTY_NAME`
+
+To set a property, use:
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.Set string:org.asamk.Signal.Group string:$PROPERTY_NAME variant:$PROPERTY_TYPE:$PROPERTY_VALUE`
+
+To get all properties, use:
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.GetAll string:org.asamk.Signal.Group`
+
+addAdmins(recipients<as>) -> <>::
+* recipients  : String array of phone numbers
+
+Grant admin privileges to recipients.
 
 Exceptions: Failure
 
-setContactBlocked(number<s>, block<b>) -> <>::
-* number  : Phone number affected by method
-* block   : 0=remove block , 1=blocked
+addMembers(recipients<as>) -> <>::
+* recipients  : String array of phone numbers
 
-Messages from blocked numbers will no longer be forwarded via DBus.
+Add recipients to group if they are pending members; otherwise add recipients to list of requesting members.
 
-Exceptions: InvalidNumber
+Exceptions: Failure
 
-setGroupBlocked(groupId<ay>, block<b>) -> <>::
-* groupId : Byte array representing the internal group identifier
-* block   : 0=remove block , 1=blocked
+disableLink() -> <>::
 
-Messages from blocked groups will no longer be forwarded via DBus.
+Disables the group's invitation link.
+
+Exceptions: Failure
 
-Exceptions: GroupNotFound
+enableLink(requiresApproval<b>) -> <>::
+* requiresApproval : true=add numbers using the link to the requesting members list
 
-joinGroup(inviteURI<s>) -> <>::
-* inviteURI : String starting with https://signal.group which is generated when you share a group link via Signal App
+Enables the group's invitation link.
+
+Exceptions: Failure
+
+quitGroup() -> <>::
+Exceptions: Failure, LastGroupAdmin
+
+removeAdmins(recipients<as>) -> <>::
+* recipients  : String array of phone numbers
+
+Remove admin privileges from recipients.
 
 Exceptions: Failure
 
+removeMembers(recipients<as>) -> <>::
+* recipients  : String array of phone numbers
+
+Remove recipients from group.
+
+Exceptions: Failure
+
+resetLink() -> <>::
+
+Resets the group's invitation link to a new random URL starting with https://signal.group/#
+
+Exceptions: Failure
+
+=== Deprecated group control methods
+The following deprecated methods listen to the recipient's object path, which is constructed as follows:
+"/org/asamk/Signal/" + DBusNumber
+* DBusNumber  : recipient's phone number, with underscore (_) replacing plus (+)
+
+getGroupIds() -> groupList<aay>::
+groupList : Array of Byte arrays representing the internal group identifiers
+
+All groups known are returned, regardless of their active or blocked status. To query that use isMember() and isGroupBlocked()
+
+Exceptions: None
+
+getGroupName(groupId<ay>) -> groupName<s>::
+* groupId   : Byte array representing the internal group identifier
+* groupName : The display name of the group
+
+Exceptions: None, if the group name is not found an empty string is returned
+
+isGroupBlocked(groupId<ay>) -> isGroupBlocked<b>::
+* groupId        : Byte array representing the internal group identifier
+* isGroupBlocked : true=group is blocked; false=group is not blocked
+
+Dbus will not forward messages from a group when you have blocked it.
+
+Exceptions: InvalidGroupId, Failure
+
+isMember(groupId<ay>) -> isMember<b>::
+* groupId   : Byte array representing the internal group identifier
+* isMember  : true=you are a group member; false=you are not a group member
+
+Note that this method does not raise an Exception for a non-existing/unknown group but will simply return 0 (false)
+
 quitGroup(groupId<ay>) -> <>::
 * groupId : Byte array representing the internal group identifier
 
 Note that quitting a group will not remove the group from the getGroupIds command, but set it inactive which can be tested with isMember()
 
-Exceptions: GroupNotFound, Failure
+Exceptions: GroupNotFound, Failure, InvalidGroupId
 
-isMember(groupId<ay>) -> active<b>::
+setGroupBlocked(groupId<ay>, block<b>) -> <>::
 * groupId : Byte array representing the internal group identifier
+* block   : false=remove block , true=blocked
 
-Note that this method does not raise an Exception for a non-existing/unknown group but will simply return 0 (false)
+Messages from blocked groups will no longer be forwarded via DBus.
 
-sendEndSessionMessage(recipients<as>) -> <>::
-* recipients : Array of phone numbers 
+Exceptions: GroupNotFound, InvalidGroupId
 
-Exceptions: Failure, InvalidNumber, UntrustedIdentity
+updateGroup(groupId<ay>, newName<s>, members<as>, avatar<s>) -> groupId<ay>::
+* groupId  : Byte array representing the internal group identifier
+* newName  : New name of group (empty if unchanged)
+* members  : String array of new members to be invited to group
+* avatar   : Filename of avatar picture to be set for group (empty if none)
 
-sendGroupMessage(message<s>, attachments<as>, groupId<ay>) -> timestamp<x>::
-* message     : Text to send (can be UTF8)
-* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
-* groupId     : Byte array representing the internal group identifier
-* timestamp   : Can be used to identify the corresponding signal reply
+Exceptions: AttachmentInvalid, Failure, InvalidNumber, GroupNotFound
+
+=== Device control methods
+The following methods listen to the recipient's object path, which is constructed as follows:
+"/org/asamk/Signal/" + DBusNumber
+* DBusNumber  : recipient's phone number, with underscore (_) replacing plus (+)
+
+addDevice(deviceUri<s>) -> <>::
+* deviceUri : URI in the form of "sgnl://linkdevice/?uuid=..." (formerly "tsdevice:/?uuid=...") Normally displayed by a Signal desktop app, smartphone app, or another signal-cli instance using the `link` control method.
+
+getDevice(deviceId<x>) -> devicePath<o>::
+* deviceId   : Long representing a deviceId
+* devicePath : DBusPath object for the device
+
+Exceptions: DeviceNotFound
+
+listDevices() -> devices<a(oxs)>::
+* devices      : Array of structs (objectPath, id, name)
+** objectPath   : DBusPath representing the device's object path
+** id           : Long representing the deviceId
+** name         : String representing the device's name
 
-Exceptions: GroupNotFound, Failure, AttachmentInvalid
+Exceptions: InvalidUri
 
 sendContacts() -> <>::
 
@@ -188,49 +337,103 @@ sendSyncRequest() -> <>::
 
 Sends a synchronization request to the primary device (for group, contacts, ...). Only works if sent from a secondary device.
 
-Exception: Failure
+Exceptions: Failure
 
-sendNoteToSelfMessage(message<s>, attachments<as>) -> timestamp<x>::
-* message     : Text to send (can be UTF8)
-* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
-* timestamp   : Can be used to identify the corresponding signal reply
+=== Device methods and properties
+The following methods listen to the device's object path, which is constructed as follows:
+"/org/asamk/Signal/" + DBusNumber + "/Devices/" + deviceId
+* DBusNumber  : recipient's phone number, with underscore (_) replacing plus (+)
+* deviceId    : Long representing the device identifier (obtained from listDevices() method)
 
-Exceptions: Failure, AttachmentInvalid
+Devices have the following (case-sensitive) properties:
+* Id<x> (read-only)       : Long representing the device identifier
+* Created<x> (read-only)  : Long representing the number of milliseconds since the Unix epoch
+* LastSeen<x> (read-only) : Long representing the number of milliseconds since the Unix epoch
+* Name<s>                 : String representing the display name of the device
 
-sendMessage(message<s>, attachments<as>, recipient<s>) -> timestamp<x>::
-sendMessage(message<s>, attachments<as>, recipients<as>) -> timestamp<x>::
-* message     : Text to send (can be UTF8)
-* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
-* recipient   : Phone number of a single recipient
-* recipients  : Array of phone numbers 
-* timestamp   : Can be used to identify the corresponding signal reply
+To get a property, use (replacing `--session` with `--system` if needed):
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.Get string:org.asamk.Signal.Device string:$PROPERTY_NAME`
 
-Depending on the type of the recipient field this sends a message to one or multiple recipients.
+To set a property, use:
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.Set string:org.asamk.Signal.Device string:$PROPERTY_NAME variant:$PROPERTY_TYPE:$PROPERTY_VALUE`
 
-Exceptions: AttachmentInvalid, Failure, InvalidNumber, UntrustedIdentity
+To get all properties, use:
+`dbus-send --session --dest=org.asamk.Signal --print-reply $OBJECT_PATH org.freedesktop.DBus.Properties.GetAll string:org.asamk.Signal.Device`
 
-sendTyping(recipient<s>, stop<b>) -> <>::
-* recipient             : Phone number of a single recipient
-* targetSentTimestamp   : True, if typing state should be stopped
+removeDevice() -> <>::
 
-Exceptions: Failure, GroupNotFound, UntrustedIdentity
+Exceptions: Failure
 
+=== Other methods
 
-sendReadReceipt(recipient<s>, targetSentTimestamp<ax>) -> <>::
-* recipient             : Phone number of a single recipient
-* targetSentTimestamp   : Array of Longs to identify the corresponding signal messages
+getContactName(number<s>) -> name<s>::
+* number  : Phone number
+* name    : Contact's name in local storage (from the master device for a linked account, or the one set with setContactName); if not set, contact's profile name is used
 
-Exceptions: Failure, UntrustedIdentity
+Exceptions: None
 
-sendGroupMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
-* emoji               : Unicode grapheme cluster of the emoji
-* remove              : Boolean, whether a previously sent reaction (emoji) should be removed
-* targetAuthor        : String with the phone number of the author of the message to which to react
-* targetSentTimestamp : Long representing timestamp of the message to which to react
-* groupId             : Byte array with base64 encoded group identifier
-* timestamp           : Long, can be used to identify the corresponding signal reply
+getContactNumber(name<s>) -> numbers<as>::
+* numbers : Array of phone number
+* name    : Contact or profile name ("firstname lastname")
+
+Searches contacts and known profiles for a given name and returns the list of all known numbers. May result in e.g. two entries if a contact and profile name is set.
+
+Exceptions: None
+
+getSelfNumber() -> number<s>::
+* number : Your phone number
+
+Exceptions: None
+
+isContactBlocked(number<s>) -> blocked<b>::
+* number    : Phone number
+* blocked   : true=blocked, false=not blocked
+
+For unknown numbers false is returned but no exception is raised.
+
+Exceptions: InvalidPhoneNumber
+
+isRegistered() -> result<b>::
+isRegistered(number<s>) -> result<b>::
+isRegistered(numbers<as>) -> results<ab>::
+* number  : Phone number
+* numbers : String array of phone numbers
+* result  : true=number is registered, false=number is not registered
+* results : Boolean array of results
+
+For unknown numbers, false is returned, but no exception is raised. If no number is given, returns true (indicating that you are registered).
+
+Exceptions: InvalidNumber
+
+listNumbers() -> numbers<as>::
+* numbers : String array of all known numbers
+
+This is a concatenated list of all defined contacts as well of profiles known (e.g. peer group members or sender of received messages)
+
+Exceptions: None
+
+removePin() -> <>::
+
+Removes registration PIN protection.
+
+Exceptions: Failure
+
+sendEndSessionMessage(recipients<as>) -> <>::
+* recipients : Array of phone numbers
+
+Exceptions: Failure, InvalidNumber, UntrustedIdentity
+
+sendMessage(message<s>, attachments<as>, recipient<s>) -> timestamp<x>::
+sendMessage(message<s>, attachments<as>, recipients<as>) -> timestamp<x>::
+* message     : Text to send (can be UTF8)
+* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
+* recipient   : Phone number of a single recipient
+* recipients  : String array of phone numbers
+* timestamp   : Long, can be used to identify the corresponding Signal reply
 
-Exceptions: Failure, InvalidNumber, GroupNotFound
+Depending on the type of the recipient field this sends a message to one or multiple recipients.
+
+Exceptions: AttachmentInvalid, Failure, InvalidNumber, UntrustedIdentity
 
 sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
 sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
@@ -240,18 +443,24 @@ sendMessageReaction(emoji<s>, remove<b>, targetAuthor<s>, targetSentTimestamp<x>
 * targetSentTimestamp : Long representing timestamp of the message to which to react
 * recipient           : String with the phone number of a single recipient
 * recipients          : Array of strings with phone numbers, should there be more recipients
-* timestamp           : Long, can be used to identify the corresponding signal reply
+* timestamp           : Long, can be used to identify the corresponding Signal reply
 
 Depending on the type of the recipient(s) field this sends a reaction to one or multiple recipients.
 
 Exceptions: Failure, InvalidNumber
 
-sendGroupRemoteDeleteMessage(targetSentTimestamp<x>, groupId<ay>) -> timestamp<x>::
-* targetSentTimestamp : Long representing timestamp of the message to delete
-* groupId             : Byte array with base64 encoded group identifier
-* timestamp           : Long, can be used to identify the corresponding signal reply
+sendNoteToSelfMessage(message<s>, attachments<as>) -> timestamp<x>::
+* message     : Text to send (can be UTF8)
+* attachments : String array of filenames to send as attachments (passed as filename, so need to be readable by the user signal-cli is running under)
+* timestamp   : Long, can be used to identify the corresponding Signal reply
 
-Exceptions: Failure, GroupNotFound
+Exceptions: Failure, AttachmentInvalid
+
+sendReadReceipt(recipient<s>, targetSentTimestamps<ax>) -> <>::
+* recipient             : Phone number of a single recipient
+* targetSentTimestamps  : Array of Longs to identify the corresponding Signal messages
+
+Exceptions: Failure, UntrustedIdentity
 
 sendRemoteDeleteMessage(targetSentTimestamp<x>, recipient<s>) -> timestamp<x>::
 sendRemoteDeleteMessage(targetSentTimestamp<x>, recipients<as>) -> timestamp<x>::
@@ -264,104 +473,57 @@ Depending on the type of the recipient(s) field this deletes a message with one
 
 Exceptions: Failure, InvalidNumber
 
-getContactName(number<s>) -> name<s>::
-* number  : Phone number
-* name    : Contact's name in local storage (from the primary device for a linked account, or the one set with setContactName); if not set, contact's profile name is used
-
-setContactName(number<s>,name<>) -> <>::
-* number  : Phone number
-* name    : Name to be set in contacts (in local storage with signal-cli)
-
-getGroupIds() -> groupList<aay>::
-groupList : Array of Byte arrays representing the internal group identifiers
-
-All groups known are returned, regardless of their active or blocked status. To query that use isMember() and isGroupBlocked()
-
-getGroupName(groupId<ay>) -> groupName<s>::
-groupName : The display name of the group 
-groupId   : Byte array representing the internal group identifier
-
-Exceptions: None, if the group name is not found an empty string is returned
-
-getGroupMembers(groupId<ay>) -> members<as>::
-members   : String array with the phone numbers of all active members of a group
-groupId   : Byte array representing the internal group identifier
-
-Exceptions: None, if the group name is not found an empty array is returned
+sendTyping(recipient<s>, stop<b>) -> <>::
+* recipient             : Phone number of a single recipient
+* targetSentTimestamp   : True, if typing state should be stopped
 
-listNumbers() -> numbers<as>::
-numbers : String array of all known numbers
+Exceptions: Failure, GroupNotFound, UntrustedIdentity
 
-This is a concatenated list of all defined contacts as well of profiles known (e.g. peer group members or sender of received messages)
+setContactBlocked(number<s>, block<b>) -> <>::
+* number  : Phone number affected by method
+* block   : false=remove block, true=blocked
 
-getContactNumber(name<s>) -> numbers<as>::
-* numbers : Array of phone number
-* name    : Contact or profile name ("firstname lastname")
+Messages from blocked numbers will no longer be forwarded via DBus.
 
-Searches contacts and known profiles for a given name and returns the list of all known numbers. May result in e.g. two entries if a contact and profile name is set.
+Exceptions: InvalidNumber
 
-isContactBlocked(number<s>) -> state<b>::
+setContactName(number<s>,name<>) -> <>::
 * number  : Phone number
-* state   : 1=blocked, 0=not blocked
-
-Exceptions: None, for unknown numbers 0 (false) is returned
-
-isGroupBlocked(groupId<ay>) -> state<b>::
-* groupId : Byte array representing the internal group identifier
-* state   : 1=blocked, 0=not blocked
-
-Exceptions: None, for unknown groups 0 (false) is returned
+* name    : Name to be set in contacts (in local storage with signal-cli)
 
-removePin() -> <>::
+Exceptions: InvalidNumber, Failure
 
-Removes registration PIN protection.
+setExpirationTimer(number<s>, expiration<i>) -> <>::
+* number     : Phone number of recipient
+* expiration : int32 for the number of seconds before messages to this recipient disappear. Set to 0 to disable expiration.
 
-Exception: Failure
+Exceptions: Failure, InvalidNumber
 
 setPin(pin<s>) -> <>::
 * pin               : PIN you set after registration (resets after 7 days of inactivity)
 
 Sets a registration lock PIN, to prevent others from registering your number.
 
-Exception: Failure
-
-version() -> version<s>::
-* version : Version string of signal-cli
-
-isRegistered() -> result<b>::
-isRegistered(number<s>) -> result<b>::
-isRegistered(numbers<as>) -> results<ab>::
-* number  : Phone number
-* numbers : String array of phone numbers
-* result  : true=number is registered, false=number is not registered
-* results : Boolean array of results
-
-Exception: InvalidNumber for an incorrectly formatted phone number. For unknown numbers, false is returned, but no exception is raised. If no number is given, returns whether you are registered (presumably true).
-
-addDevice(deviceUri<s>) -> <>::
-* deviceUri : URI in the form of tsdevice:/?uuid=... Normally received from Signal desktop or smartphone app
-
-Exception: InvalidUri
-
-listDevices() -> devices<a(oxs)>::
-* devices      : Array of structs (objectPath, id, name)
-** objectPath   : DBusPath representing the device's object path
-** id           : Long representing the deviceId
-** name         : String representing the device's name
-
-Exception: Failure
-
-removeDevice(deviceId<i>) -> <>::
-* deviceId : Device ID to remove, obtained from listDevices() command
+Exceptions: Failure
 
-Exception: Failure
+submitRateLimitChallenge(challenge<s>, captcha<s>) -> <>::
+* challenge : The challenge token taken from the proof required error.
+* captcha   : The captcha token from the solved captcha on the Signal website..
+Can be used to lift some rate-limits by solving a captcha.
 
-updateDeviceName(deviceName<s>) -> <>::
-* deviceName : New name 
+Exception: IOErrorException
 
-Set a new name for this device (main or linked).
+updateProfile(name<s>, about<s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
+updateProfile(givenName<s>, familyName<s>, about<s>, aboutEmoji <s>, avatar<s>, remove<b>) -> <>::
+* name        : Name for your own profile (empty if unchanged)
+* givenName   : Given name for your own profile (empty if unchanged)
+* familyName  : Family name for your own profile (empty if unchanged)
+* about       : About message for profile (empty if unchanged)
+* aboutEmoji  : Emoji for profile (empty if unchanged)
+* avatar      : Filename of avatar picture for profile (empty if unchanged)
+* remove      : Set to true if the existing avatar picture should be removed
 
-Exception: Failure
+Exceptions: Failure
 
 uploadStickerPack(stickerPackPath<s>) -> url<s>::
 * stickerPackPath : Path to the manifest.json file or a zip file in the same directory
@@ -389,15 +551,20 @@ Update Signal configurations and sync them to linked devices. Only works from pr
 
 Exceptions: IOError, UserError
 
-submitRateLimitChallenge(challenge<s>, captcha<s>) -> <>::
-* challenge : The challenge token taken from the proof required error.
-* captcha   : The captcha token from the solved captcha on the Signal website..
-Can be used to lift some rate-limits by solving a captcha.
+version() -> version<s>::
+* version : Version string of signal-cli
 
-Exception: IOErrorException
+Exceptions: None
 
 == Signals
-SyncMessageReceived (timestamp<x>, sender<s>, destination<s>, groupId<ay>,message<s>, attachments<as>)::
+SyncMessageReceived (timestamp<x>, sender<s>, destination<s>, groupId<ay>, message<s>, attachments<as>)::
+* timestamp   : Integer value that can be used to associate this e.g. with a sendMessage()
+* sender      : Phone number of the sender
+* destination : DBus code for destination
+* groupId     : Byte array representing the internal group identifier (empty when private message)
+* message     : Message text
+* attachments : String array of filenames in the signal-cli storage (~/.local/share/signal-cli/attachments/)
+
 The sync message is received when the user sends a message from a linked device.
 
 ReceiptReceived (timestamp<x>, sender<s>)::
@@ -411,7 +578,7 @@ MessageReceived(timestamp<x>, sender<s>, groupId<ay>, message<s>, attachments<as
 * sender      : Phone number of the sender
 * groupId     : Byte array representing the internal group identifier (empty when private message)
 * message     : Message text
-* attachments : String array of filenames for the attachments. These files are located in the signal-cli storage and the current user needs to have read access there
+* attachments : String array of filenames in the signal-cli storage (~/.local/share/signal-cli/attachments/)
 
 This signal is received whenever we get a private message or a message is posted in a group we are an active member
 
index 9829fe0006cb009dc0768310636275e36c797f44..f2a1a960d78981070b55c1373a12fd5c6633a550 100644 (file)
@@ -144,7 +144,7 @@ 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.
+This shows a "sgnl://linkdevice/?uuid=..." 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::
@@ -158,7 +158,8 @@ 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=....."
+You will need the full URI such as "sgnl://linkdevice/?uuid=..." (formerly "tsdevice:/?uuid=...")
+Make sure to enclose it in quotation marks for shells.
 
 === listDevices
 
index 865db815c14a26745c4a2ff237028b4184c11eeb..28c192a09557281bac20361cd4b9058c14925fe3 100644 (file)
@@ -1,7 +1,6 @@
 package org.asamk;
 
 import org.asamk.signal.commands.exceptions.IOErrorException;
-
 import org.freedesktop.dbus.DBusPath;
 import org.freedesktop.dbus.Struct;
 import org.freedesktop.dbus.annotations.DBusProperty;
@@ -84,14 +83,27 @@ public interface Signal extends DBusInterface {
 
     void setContactBlocked(String number, boolean blocked) throws Error.InvalidNumber;
 
+    @Deprecated
     void setGroupBlocked(byte[] groupId, boolean blocked) throws Error.GroupNotFound, Error.InvalidGroupId;
 
+    @Deprecated
     List<byte[]> getGroupIds();
 
+    DBusPath getGroup(byte[] groupId);
+
+    List<StructGroup> listGroups();
+
+    @Deprecated
     String getGroupName(byte[] groupId) throws Error.InvalidGroupId;
 
+    @Deprecated
     List<String> getGroupMembers(byte[] groupId) throws Error.InvalidGroupId;
 
+    byte[] createGroup(
+            String name, List<String> members, String avatar
+    ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber;
+
+    @Deprecated
     byte[] updateGroup(
             byte[] groupId, String name, List<String> members, String avatar
     ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber, Error.GroupNotFound, Error.InvalidGroupId;
@@ -133,12 +145,15 @@ public interface Signal extends DBusInterface {
 
     List<String> getContactNumber(final String name) throws Error.Failure;
 
+    @Deprecated
     void quitGroup(final byte[] groupId) throws Error.GroupNotFound, Error.Failure, Error.InvalidGroupId;
 
     boolean isContactBlocked(final String number) throws Error.InvalidNumber;
 
+    @Deprecated
     boolean isGroupBlocked(final byte[] groupId) throws Error.InvalidGroupId;
 
+    @Deprecated
     boolean isMember(final byte[] groupId) throws Error.InvalidGroupId;
 
     byte[] joinGroup(final String groupLink) throws Error.Failure;
@@ -307,6 +322,71 @@ public interface Signal extends DBusInterface {
         void removeDevice() throws Error.Failure;
     }
 
+    class StructGroup extends Struct {
+
+        @Position(0)
+        DBusPath objectPath;
+
+        @Position(1)
+        byte[] id;
+
+        @Position(2)
+        String name;
+
+        public StructGroup(final DBusPath objectPath, final byte[] id, final String name) {
+            this.objectPath = objectPath;
+            this.id = id;
+            this.name = name;
+        }
+
+        public DBusPath getObjectPath() {
+            return objectPath;
+        }
+
+        public byte[] getId() {
+            return id;
+        }
+
+        public String getName() {
+            return name;
+        }
+    }
+
+    @DBusProperty(name = "Id", type = Byte[].class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "Name", type = String.class)
+    @DBusProperty(name = "Description", type = String.class)
+    @DBusProperty(name = "Avatar", type = String.class, access = DBusProperty.Access.WRITE)
+    @DBusProperty(name = "IsBlocked", type = Boolean.class)
+    @DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "MessageExpirationTimer", type = Integer.class)
+    @DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "RequestingMembers", type = String[].class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "Admins", type = String[].class, access = DBusProperty.Access.READ)
+    @DBusProperty(name = "PermissionAddMember", type = String.class)
+    @DBusProperty(name = "PermissionEditDetails", type = String.class)
+    @DBusProperty(name = "PermissionSendMessage", type = String.class)
+    @DBusProperty(name = "GroupInviteLink", type = String.class, access = DBusProperty.Access.READ)
+    interface Group extends DBusInterface, Properties {
+
+        void quitGroup() throws Error.Failure, Error.LastGroupAdmin;
+
+        void addMembers(List<String> recipients) throws Error.Failure;
+
+        void removeMembers(List<String> recipients) throws Error.Failure;
+
+        void addAdmins(List<String> recipients) throws Error.Failure;
+
+        void removeAdmins(List<String> recipients) throws Error.Failure;
+
+        void resetLink() throws Error.Failure;
+
+        void disableLink() throws Error.Failure;
+
+        void enableLink(boolean requiresApproval) throws Error.Failure;
+    }
+
     interface Error {
 
         class AttachmentInvalid extends DBusExecutionException {
@@ -330,6 +410,13 @@ public interface Signal extends DBusInterface {
             }
         }
 
+        class DeviceNotFound extends DBusExecutionException {
+
+            public DeviceNotFound(final String message) {
+                super(message);
+            }
+        }
+
         class GroupNotFound extends DBusExecutionException {
 
             public GroupNotFound(final String message) {
@@ -344,6 +431,13 @@ public interface Signal extends DBusInterface {
             }
         }
 
+        class LastGroupAdmin extends DBusExecutionException {
+
+            public LastGroupAdmin(final String message) {
+                super(message);
+            }
+        }
+
         class InvalidNumber extends DBusExecutionException {
 
             public InvalidNumber(final String message) {
index 35790678cd379fb3c331cf121ae48da2cfc884c2..b1580b349b0088d99fdea712caf2a35aff447299 100644 (file)
@@ -6,7 +6,6 @@ import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupUtils;
 import org.asamk.signal.util.DateUtils;
-import org.asamk.signal.util.Util;
 import org.slf4j.helpers.MessageFormatter;
 import org.whispersystems.libsignal.protocol.DecryptionErrorMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -377,9 +376,6 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
             writer.println("Received sync message with verified identities:");
             final var verifiedMessage = syncMessage.getVerified().get();
             writer.println("- {}: {}", formatContact(verifiedMessage.getDestination()), verifiedMessage.getVerified());
-            var safetyNumber = Util.formatSafetyNumber(m.computeSafetyNumber(verifiedMessage.getDestination(),
-                    verifiedMessage.getIdentityKey()));
-            writer.indentedWriter().println(safetyNumber);
         }
         if (syncMessage.getConfiguration().isPresent()) {
             writer.println("Received sync message with configuration:");
index 516224f550cd6790eec90d9d0fd4c81c615d375e..1ec1036cd828defa7bc508c2c5aa38aedad98239 100644 (file)
@@ -52,6 +52,8 @@ public class BlockCommand implements JsonRpcLocalCommand {
             for (var groupId : CommandUtil.getGroupIds(groupIdStrings)) {
                 try {
                     m.setGroupBlocked(groupId, true);
+                } catch (NotMasterDeviceException e) {
+                    throw new UserErrorException("This command doesn't work on linked devices.");
                 } catch (GroupNotFoundException e) {
                     logger.warn("Group not found {}: {}", groupId.toBase64(), e.getMessage());
                 } catch (IOException e) {
index fd8c4b92141bdeb838276c25aa16a31fc82e63dd..b2182429d30648de5de762149d5ef50bcc2e80e9 100644 (file)
@@ -63,7 +63,7 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
                     resolveMembers(group.getPendingMembers()),
                     resolveMembers(group.getRequestingMembers()),
                     resolveMembers(group.getAdminMembers()),
-                    group.getMessageExpirationTime() == 0 ? "disabled" : group.getMessageExpirationTime() + "s",
+                    group.getMessageExpirationTimer() == 0 ? "disabled" : group.getMessageExpirationTimer() + "s",
                     groupInviteLink == null ? '-' : groupInviteLink.getUrl());
         } else {
             writer.println("Id: {} Name: {}  Active: {} Blocked: {}",
@@ -91,11 +91,14 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
                         group.getDescription(),
                         group.isMember(),
                         group.isBlocked(),
-                        group.getMessageExpirationTime(),
+                        group.getMessageExpirationTimer(),
                         resolveJsonMembers(group.getMembers()),
                         resolveJsonMembers(group.getPendingMembers()),
                         resolveJsonMembers(group.getRequestingMembers()),
                         resolveJsonMembers(group.getAdminMembers()),
+                        group.getPermissionAddMember().name(),
+                        group.getPermissionEditDetails().name(),
+                        group.getPermissionSendMessage().name(),
                         groupInviteLink == null ? null : groupInviteLink.getUrl());
             }).collect(Collectors.toList());
 
@@ -122,6 +125,9 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
         public final Set<JsonGroupMember> pendingMembers;
         public final Set<JsonGroupMember> requestingMembers;
         public final Set<JsonGroupMember> admins;
+        public final String permissionAddMember;
+        public final String permissionEditDetails;
+        public final String permissionSendMessage;
         public final String groupInviteLink;
 
         public JsonGroup(
@@ -135,6 +141,9 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
                 Set<JsonGroupMember> pendingMembers,
                 Set<JsonGroupMember> requestingMembers,
                 Set<JsonGroupMember> admins,
+                final String permissionAddMember,
+                final String permissionEditDetails,
+                final String permissionSendMessage,
                 String groupInviteLink
         ) {
             this.id = id;
@@ -148,6 +157,9 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
             this.pendingMembers = pendingMembers;
             this.requestingMembers = requestingMembers;
             this.admins = admins;
+            this.permissionAddMember = permissionAddMember;
+            this.permissionEditDetails = permissionEditDetails;
+            this.permissionSendMessage = permissionSendMessage;
             this.groupInviteLink = groupInviteLink;
         }
     }
index 7cf209fa05fe39adf9d6c0824560c732b409f286..53eab3b3b728d5b69bfaee45c8022bb7f213504c 100644 (file)
@@ -51,6 +51,8 @@ public class UnblockCommand implements JsonRpcLocalCommand {
         for (var groupId : CommandUtil.getGroupIds(groupIdStrings)) {
             try {
                 m.setGroupBlocked(groupId, false);
+            } catch (NotMasterDeviceException e) {
+                throw new UserErrorException("This command doesn't work on linked devices.");
             } catch (GroupNotFoundException e) {
                 logger.warn("Unknown group id: {}", groupId);
             } catch (IOException e) {
index 68bce2d222a0a3756e3b4635aad57517cc5554c9..b63a7160c509f80bfaedfb2a18e824bea5eba410 100644 (file)
@@ -12,6 +12,7 @@ import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
 import org.asamk.signal.commands.exceptions.UserErrorException;
 import org.asamk.signal.manager.AttachmentInvalidException;
 import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupLinkState;
 import org.asamk.signal.manager.groups.GroupNotFoundException;
@@ -145,21 +146,23 @@ public class UpdateGroupCommand implements JsonRpcLocalCommand {
             }
 
             var results = m.updateGroup(groupId,
-                    groupName,
-                    groupDescription,
-                    groupMembers,
-                    groupRemoveMembers,
-                    groupAdmins,
-                    groupRemoveAdmins,
-                    groupResetLink,
-                    groupLinkState,
-                    groupAddMemberPermission,
-                    groupEditDetailsPermission,
-                    groupAvatar == null ? null : new File(groupAvatar),
-                    groupExpiration,
-                    groupSendMessagesPermission == null
-                            ? null
-                            : groupSendMessagesPermission == GroupPermission.ONLY_ADMINS);
+                    UpdateGroup.newBuilder()
+                            .withName(groupName)
+                            .withDescription(groupDescription)
+                            .withMembers(groupMembers)
+                            .withRemoveMembers(groupRemoveMembers)
+                            .withAdmins(groupAdmins)
+                            .withRemoveAdmins(groupRemoveAdmins)
+                            .withResetGroupLink(groupResetLink)
+                            .withGroupLinkState(groupLinkState)
+                            .withAddMemberPermission(groupAddMemberPermission)
+                            .withEditDetailsPermission(groupEditDetailsPermission)
+                            .withAvatarFile(groupAvatar == null ? null : new File(groupAvatar))
+                            .withExpirationTimer(groupExpiration)
+                            .withIsAnnouncementGroup(groupSendMessagesPermission == null
+                                    ? null
+                                    : groupSendMessagesPermission == GroupPermission.ONLY_ADMINS)
+                            .build());
             if (results != null) {
                 timestamp = results.getTimestamp();
                 ErrorUtils.handleSendMessageResults(results.getResults());
index 226402ddaa277fe8eebda1c7af6a4d1fbd00b7c5..a8f071f7a6723c4510944a12b4490c1ab305cc62 100644 (file)
@@ -15,9 +15,9 @@ import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.api.SendGroupMessageResults;
 import org.asamk.signal.manager.api.SendMessageResults;
 import org.asamk.signal.manager.api.TypingAction;
+import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
-import org.asamk.signal.manager.groups.GroupLinkState;
 import org.asamk.signal.manager.groups.GroupNotFoundException;
 import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
@@ -30,7 +30,6 @@ import org.freedesktop.dbus.DBusPath;
 import org.freedesktop.dbus.connections.impl.DBusConnection;
 import org.freedesktop.dbus.exceptions.DBusException;
 import org.freedesktop.dbus.interfaces.DBusInterface;
-import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
@@ -193,8 +192,8 @@ public class DbusManagerImpl implements Manager {
 
     @Override
     public List<Group> getGroups() {
-        final var groupIds = signal.getGroupIds();
-        return groupIds.stream().map(id -> getGroup(GroupId.unknownVersion(id))).collect(Collectors.toList());
+        final var groups = signal.listGroups();
+        return groups.stream().map(Signal.StructGroup::getObjectPath).map(this::getGroup).collect(Collectors.toList());
     }
 
     @Override
@@ -204,7 +203,8 @@ public class DbusManagerImpl implements Manager {
         if (groupAdmins.size() > 0) {
             throw new UnsupportedOperationException();
         }
-        signal.quitGroup(groupId.serialize());
+        final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class);
+        group.quitGroup();
         return new SendGroupMessageResults(0, List.of());
     }
 
@@ -217,8 +217,7 @@ public class DbusManagerImpl implements Manager {
     public Pair<GroupId, SendGroupMessageResults> createGroup(
             final String name, final Set<RecipientIdentifier.Single> members, final File avatarFile
     ) throws IOException, AttachmentInvalidException {
-        final var newGroupId = signal.updateGroup(new byte[0],
-                emptyIfNull(name),
+        final var newGroupId = signal.createGroup(emptyIfNull(name),
                 members.stream().map(RecipientIdentifier.Single::getIdentifier).collect(Collectors.toList()),
                 avatarFile == null ? "" : avatarFile.getPath());
         return new Pair<>(GroupId.unknownVersion(newGroupId), new SendGroupMessageResults(0, List.of()));
@@ -226,25 +225,76 @@ public class DbusManagerImpl implements Manager {
 
     @Override
     public SendGroupMessageResults updateGroup(
-            final GroupId groupId,
-            final String name,
-            final String description,
-            final Set<RecipientIdentifier.Single> members,
-            final Set<RecipientIdentifier.Single> removeMembers,
-            final Set<RecipientIdentifier.Single> admins,
-            final Set<RecipientIdentifier.Single> removeAdmins,
-            final boolean resetGroupLink,
-            final GroupLinkState groupLinkState,
-            final GroupPermission addMemberPermission,
-            final GroupPermission editDetailsPermission,
-            final File avatarFile,
-            final Integer expirationTimer,
-            final Boolean isAnnouncementGroup
+            final GroupId groupId, final UpdateGroup updateGroup
     ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
-        signal.updateGroup(groupId.serialize(),
-                emptyIfNull(name),
-                members.stream().map(RecipientIdentifier.Single::getIdentifier).collect(Collectors.toList()),
-                avatarFile == null ? "" : avatarFile.getPath());
+        final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class);
+        if (updateGroup.getName() != null) {
+            group.Set("org.asamk.Signal.Group", "Name", updateGroup.getName());
+        }
+        if (updateGroup.getDescription() != null) {
+            group.Set("org.asamk.Signal.Group", "Description", updateGroup.getDescription());
+        }
+        if (updateGroup.getAvatarFile() != null) {
+            group.Set("org.asamk.Signal.Group",
+                    "Avatar",
+                    updateGroup.getAvatarFile() == null ? "" : updateGroup.getAvatarFile().getPath());
+        }
+        if (updateGroup.getExpirationTimer() != null) {
+            group.Set("org.asamk.Signal.Group", "MessageExpirationTimer", updateGroup.getExpirationTimer());
+        }
+        if (updateGroup.getAddMemberPermission() != null) {
+            group.Set("org.asamk.Signal.Group", "PermissionAddMember", updateGroup.getAddMemberPermission().name());
+        }
+        if (updateGroup.getEditDetailsPermission() != null) {
+            group.Set("org.asamk.Signal.Group", "PermissionEditDetails", updateGroup.getEditDetailsPermission().name());
+        }
+        if (updateGroup.getIsAnnouncementGroup() != null) {
+            group.Set("org.asamk.Signal.Group",
+                    "PermissionSendMessage",
+                    updateGroup.getIsAnnouncementGroup()
+                            ? GroupPermission.ONLY_ADMINS.name()
+                            : GroupPermission.EVERY_MEMBER.name());
+        }
+        if (updateGroup.getMembers() != null) {
+            group.addMembers(updateGroup.getMembers()
+                    .stream()
+                    .map(RecipientIdentifier.Single::getIdentifier)
+                    .collect(Collectors.toList()));
+        }
+        if (updateGroup.getRemoveMembers() != null) {
+            group.removeMembers(updateGroup.getRemoveMembers()
+                    .stream()
+                    .map(RecipientIdentifier.Single::getIdentifier)
+                    .collect(Collectors.toList()));
+        }
+        if (updateGroup.getAdmins() != null) {
+            group.addAdmins(updateGroup.getAdmins()
+                    .stream()
+                    .map(RecipientIdentifier.Single::getIdentifier)
+                    .collect(Collectors.toList()));
+        }
+        if (updateGroup.getRemoveAdmins() != null) {
+            group.removeAdmins(updateGroup.getRemoveAdmins()
+                    .stream()
+                    .map(RecipientIdentifier.Single::getIdentifier)
+                    .collect(Collectors.toList()));
+        }
+        if (updateGroup.isResetGroupLink()) {
+            group.resetLink();
+        }
+        if (updateGroup.getGroupLinkState() != null) {
+            switch (updateGroup.getGroupLinkState()) {
+                case DISABLED:
+                    group.disableLink();
+                    break;
+                case ENABLED:
+                    group.enableLink(false);
+                    break;
+                case ENABLED_WITH_APPROVAL:
+                    group.enableLink(true);
+                    break;
+            }
+        }
         return new SendGroupMessageResults(0, List.of());
     }
 
@@ -354,7 +404,12 @@ public class DbusManagerImpl implements Manager {
     public void setGroupBlocked(
             final GroupId groupId, final boolean blocked
     ) throws GroupNotFoundException, IOException {
-        signal.setGroupBlocked(groupId.serialize(), blocked);
+        setGroupProperty(groupId, "IsBlocked", blocked);
+    }
+
+    private void setGroupProperty(final GroupId groupId, final String propertyName, final boolean blocked) {
+        final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class);
+        group.Set("org.asamk.Signal.Group", propertyName, blocked);
     }
 
     @Override
@@ -421,19 +476,41 @@ public class DbusManagerImpl implements Manager {
 
     @Override
     public Group getGroup(final GroupId groupId) {
-        final var id = groupId.serialize();
-        return new Group(groupId,
-                signal.getGroupName(id),
-                null,
-                null,
-                signal.getGroupMembers(id).stream().map(m -> new RecipientAddress(null, m)).collect(Collectors.toSet()),
-                Set.of(),
-                Set.of(),
-                Set.of(),
-                signal.isGroupBlocked(id),
-                0,
-                false,
-                signal.isMember(id));
+        final var groupPath = signal.getGroup(groupId.serialize());
+        return getGroup(groupPath);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Group getGroup(final DBusPath groupPath) {
+        final var group = getRemoteObject(groupPath, Signal.Group.class).GetAll("org.asamk.Signal.Group");
+        final var id = (byte[]) group.get("Id").getValue();
+        try {
+            return new Group(GroupId.unknownVersion(id),
+                    (String) group.get("Name").getValue(),
+                    (String) group.get("Description").getValue(),
+                    GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
+                    ((List<String>) group.get("Members").getValue()).stream()
+                            .map(m -> new RecipientAddress(null, m))
+                            .collect(Collectors.toSet()),
+                    ((List<String>) group.get("PendingMembers").getValue()).stream()
+                            .map(m -> new RecipientAddress(null, m))
+                            .collect(Collectors.toSet()),
+                    ((List<String>) group.get("RequestingMembers").getValue()).stream()
+                            .map(m -> new RecipientAddress(null, m))
+                            .collect(Collectors.toSet()),
+                    ((List<String>) group.get("Admins").getValue()).stream()
+                            .map(m -> new RecipientAddress(null, m))
+                            .collect(Collectors.toSet()),
+                    (boolean) group.get("IsBlocked").getValue(),
+                    (int) group.get("MessageExpirationTimer").getValue(),
+                    GroupPermission.valueOf((String) group.get("PermissionAddMember").getValue()),
+                    GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()),
+                    GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()),
+                    (boolean) group.get("IsMember").getValue(),
+                    (boolean) group.get("IsAdmin").getValue());
+        } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
+            throw new AssertionError(e);
+        }
     }
 
     @Override
@@ -470,13 +547,6 @@ public class DbusManagerImpl implements Manager {
         throw new UnsupportedOperationException();
     }
 
-    @Override
-    public String computeSafetyNumber(
-            final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey
-    ) {
-        throw new UnsupportedOperationException();
-    }
-
     @Override
     public SignalServiceAddress resolveSignalServiceAddress(final SignalServiceAddress address) {
         return address;
index e055778699b63ba7bf98c85e57733b7a230b4276..5042458ebd269198e7f401693dbffb30a08b30e8 100644 (file)
@@ -21,6 +21,12 @@ public class DbusProperty<T> {
         this.setter = null;
     }
 
+    public DbusProperty(final String name, final Consumer<T> setter) {
+        this.name = name;
+        this.getter = null;
+        this.setter = setter;
+    }
+
     public String getName() {
         return name;
     }
index 41eca5da388306c979e8011dd8876fb0a9c0f38b..c6e3273ae4bfdad834908ede0837dd6df4883498 100644 (file)
@@ -1,7 +1,6 @@
 package org.asamk.signal.dbus;
 
 import org.asamk.Signal;
-import org.asamk.Signal.Error;
 import org.asamk.signal.BaseConfig;
 import org.asamk.signal.commands.exceptions.IOErrorException;
 import org.asamk.signal.manager.AttachmentInvalidException;
@@ -13,9 +12,12 @@ import org.asamk.signal.manager.api.Identity;
 import org.asamk.signal.manager.api.Message;
 import org.asamk.signal.manager.api.RecipientIdentifier;
 import org.asamk.signal.manager.api.TypingAction;
+import org.asamk.signal.manager.api.UpdateGroup;
 import org.asamk.signal.manager.groups.GroupId;
 import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
+import org.asamk.signal.manager.groups.GroupLinkState;
 import org.asamk.signal.manager.groups.GroupNotFoundException;
+import org.asamk.signal.manager.groups.GroupPermission;
 import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.groups.LastGroupAdminException;
 import org.asamk.signal.manager.groups.NotAGroupMemberException;
@@ -26,6 +28,7 @@ import org.freedesktop.dbus.DBusPath;
 import org.freedesktop.dbus.connections.impl.DBusConnection;
 import org.freedesktop.dbus.exceptions.DBusException;
 import org.freedesktop.dbus.exceptions.DBusExecutionException;
+import org.freedesktop.dbus.types.Variant;
 import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
@@ -40,6 +43,8 @@ import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -58,6 +63,7 @@ public class DbusSignalImpl implements Signal {
 
     private DBusPath thisDevice;
     private final List<StructDevice> devices = new ArrayList<>();
+    private final List<StructGroup> groups = new ArrayList<>();
 
     public DbusSignalImpl(final Manager m, DBusConnection connection, final String objectPath) {
         this.m = m;
@@ -67,6 +73,7 @@ public class DbusSignalImpl implements Signal {
 
     public void initObjects() {
         updateDevices();
+        updateGroups();
     }
 
     public void close() {
@@ -111,7 +118,11 @@ public class DbusSignalImpl implements Signal {
     @Override
     public DBusPath getDevice(long deviceId) {
         updateDevices();
-        return new DBusPath(getDeviceObjectPath(objectPath, deviceId));
+        final var deviceOptional = devices.stream().filter(g -> g.getId().equals(deviceId)).findFirst();
+        if (deviceOptional.isEmpty()) {
+            throw new Error.DeviceNotFound("Device not found");
+        }
+        return deviceOptional.get().getObjectPath();
     }
 
     @Override
@@ -394,6 +405,8 @@ public class DbusSignalImpl implements Signal {
     public void setGroupBlocked(final byte[] groupId, final boolean blocked) {
         try {
             m.setGroupBlocked(getGroupId(groupId), blocked);
+        } catch (NotMasterDeviceException e) {
+            throw new Error.Failure("This command doesn't work on linked devices.");
         } catch (GroupNotFoundException e) {
             throw new Error.GroupNotFound(e.getMessage());
         } catch (IOException e) {
@@ -411,6 +424,22 @@ public class DbusSignalImpl implements Signal {
         return ids;
     }
 
+    @Override
+    public DBusPath getGroup(final byte[] groupId) {
+        updateGroups();
+        final var groupOptional = groups.stream().filter(g -> Arrays.equals(g.getId(), groupId)).findFirst();
+        if (groupOptional.isEmpty()) {
+            throw new Error.GroupNotFound("Group not found");
+        }
+        return groupOptional.get().getObjectPath();
+    }
+
+    @Override
+    public List<StructGroup> listGroups() {
+        updateGroups();
+        return groups;
+    }
+
     @Override
     public String getGroupName(final byte[] groupId) {
         var group = m.getGroup(getGroupId(groupId));
@@ -427,10 +456,18 @@ public class DbusSignalImpl implements Signal {
         if (group == null) {
             return List.of();
         } else {
-            return group.getMembers().stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toList());
+            final var members = group.getMembers();
+            return getRecipientStrings(members);
         }
     }
 
+    @Override
+    public byte[] createGroup(
+            final String name, final List<String> members, final String avatar
+    ) throws Error.AttachmentInvalid, Error.Failure, Error.InvalidNumber {
+        return updateGroup(new byte[0], name, members, avatar);
+    }
+
     @Override
     public byte[] updateGroup(byte[] groupId, String name, List<String> members, String avatar) {
         try {
@@ -444,19 +481,11 @@ public class DbusSignalImpl implements Signal {
                 return results.first().serialize();
             } else {
                 final var results = m.updateGroup(getGroupId(groupId),
-                        name,
-                        null,
-                        memberIdentifiers,
-                        null,
-                        null,
-                        null,
-                        false,
-                        null,
-                        null,
-                        null,
-                        avatar == null ? null : new File(avatar),
-                        null,
-                        null);
+                        UpdateGroup.newBuilder()
+                                .withName(name)
+                                .withMembers(memberIdentifiers)
+                                .withAvatarFile(avatar == null ? null : new File(avatar))
+                                .build());
                 if (results != null) {
                     checkSendMessageResults(results.getTimestamp(), results.getResults());
                 }
@@ -760,6 +789,10 @@ public class DbusSignalImpl implements Signal {
         throw new Error.Failure(message.toString());
     }
 
+    private static List<String> getRecipientStrings(final Set<RecipientAddress> members) {
+        return members.stream().map(RecipientAddress::getLegacyIdentifier).collect(Collectors.toList());
+    }
+
     private static Set<RecipientIdentifier.Single> getSingleRecipientIdentifiers(
             final Collection<String> recipientStrings, final String localNumber
     ) throws DBusExecutionException {
@@ -837,6 +870,38 @@ public class DbusSignalImpl implements Signal {
         this.devices.clear();
     }
 
+    private static String getGroupObjectPath(String basePath, byte[] groupId) {
+        return basePath + "/Groups/" + Base64.getEncoder()
+                .encodeToString(groupId)
+                .replace("+", "_")
+                .replace("/", "_")
+                .replace("=", "_");
+    }
+
+    private void updateGroups() {
+        List<org.asamk.signal.manager.api.Group> groups;
+        groups = m.getGroups();
+
+        unExportGroups();
+
+        groups.forEach(g -> {
+            final var object = new DbusSignalGroupImpl(g.getGroupId());
+            try {
+                connection.exportObject(object);
+            } catch (DBusException e) {
+                e.printStackTrace();
+            }
+            this.groups.add(new StructGroup(new DBusPath(object.getObjectPath()),
+                    g.getGroupId().serialize(),
+                    emptyIfNull(g.getTitle())));
+        });
+    }
+
+    private void unExportGroups() {
+        this.groups.stream().map(StructGroup::getObjectPath).map(DBusPath::getPath).forEach(connection::unExportObject);
+        this.groups.clear();
+    }
+
     public class DbusSignalDeviceImpl extends DbusProperties implements Signal.Device {
 
         private final org.asamk.signal.manager.api.Device device;
@@ -878,4 +943,168 @@ public class DbusSignalImpl implements Signal {
             }
         }
     }
+
+    public class DbusSignalGroupImpl extends DbusProperties implements Signal.Group {
+
+        private final GroupId groupId;
+
+        public DbusSignalGroupImpl(final GroupId groupId) {
+            this.groupId = groupId;
+            super.addPropertiesHandler(new DbusInterfacePropertiesHandler("org.asamk.Signal.Group",
+                    List.of(new DbusProperty<>("Id", groupId::serialize),
+                            new DbusProperty<>("Name", () -> emptyIfNull(getGroup().getTitle()), this::setGroupName),
+                            new DbusProperty<>("Description",
+                                    () -> emptyIfNull(getGroup().getDescription()),
+                                    this::setGroupDescription),
+                            new DbusProperty<>("Avatar", this::setGroupAvatar),
+                            new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked),
+                            new DbusProperty<>("IsMember", () -> getGroup().isMember()),
+                            new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()),
+                            new DbusProperty<>("MessageExpirationTimer",
+                                    () -> getGroup().getMessageExpirationTimer(),
+                                    this::setMessageExpirationTime),
+                            new DbusProperty<>("Members",
+                                    () -> new Variant<>(getRecipientStrings(getGroup().getMembers()), "as")),
+                            new DbusProperty<>("PendingMembers",
+                                    () -> new Variant<>(getRecipientStrings(getGroup().getPendingMembers()), "as")),
+                            new DbusProperty<>("RequestingMembers",
+                                    () -> new Variant<>(getRecipientStrings(getGroup().getRequestingMembers()), "as")),
+                            new DbusProperty<>("Admins",
+                                    () -> new Variant<>(getRecipientStrings(getGroup().getAdminMembers()), "as")),
+                            new DbusProperty<>("PermissionAddMember",
+                                    () -> getGroup().getPermissionAddMember().name(),
+                                    this::setGroupPermissionAddMember),
+                            new DbusProperty<>("PermissionEditDetails",
+                                    () -> getGroup().getPermissionEditDetails().name(),
+                                    this::setGroupPermissionEditDetails),
+                            new DbusProperty<>("PermissionSendMessage",
+                                    () -> getGroup().getPermissionSendMessage().name(),
+                                    this::setGroupPermissionSendMessage),
+                            new DbusProperty<>("GroupInviteLink", () -> {
+                                final var groupInviteLinkUrl = getGroup().getGroupInviteLinkUrl();
+                                return groupInviteLinkUrl == null ? "" : groupInviteLinkUrl.getUrl();
+                            }))));
+        }
+
+        @Override
+        public String getObjectPath() {
+            return getGroupObjectPath(objectPath, groupId.serialize());
+        }
+
+        @Override
+        public void quitGroup() throws Error.Failure {
+            try {
+                m.quitGroup(groupId, Set.of());
+            } catch (GroupNotFoundException | NotAGroupMemberException e) {
+                throw new Error.GroupNotFound(e.getMessage());
+            } catch (IOException e) {
+                throw new Error.Failure(e.getMessage());
+            } catch (LastGroupAdminException e) {
+                throw new Error.LastGroupAdmin(e.getMessage());
+            }
+        }
+
+        @Override
+        public void addMembers(final List<String> recipients) throws Error.Failure {
+            final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());
+            updateGroup(UpdateGroup.newBuilder().withMembers(memberIdentifiers).build());
+        }
+
+        @Override
+        public void removeMembers(final List<String> recipients) throws Error.Failure {
+            final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());
+            updateGroup(UpdateGroup.newBuilder().withRemoveMembers(memberIdentifiers).build());
+        }
+
+        @Override
+        public void addAdmins(final List<String> recipients) throws Error.Failure {
+            final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());
+            updateGroup(UpdateGroup.newBuilder().withAdmins(memberIdentifiers).build());
+        }
+
+        @Override
+        public void removeAdmins(final List<String> recipients) throws Error.Failure {
+            final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());
+            updateGroup(UpdateGroup.newBuilder().withRemoveAdmins(memberIdentifiers).build());
+        }
+
+        @Override
+        public void resetLink() throws Error.Failure {
+            updateGroup(UpdateGroup.newBuilder().withResetGroupLink(true).build());
+        }
+
+        @Override
+        public void disableLink() throws Error.Failure {
+            updateGroup(UpdateGroup.newBuilder().withGroupLinkState(GroupLinkState.DISABLED).build());
+        }
+
+        @Override
+        public void enableLink(final boolean requiresApproval) throws Error.Failure {
+            updateGroup(UpdateGroup.newBuilder()
+                    .withGroupLinkState(requiresApproval
+                            ? GroupLinkState.ENABLED_WITH_APPROVAL
+                            : GroupLinkState.ENABLED)
+                    .build());
+        }
+
+        private org.asamk.signal.manager.api.Group getGroup() {
+            return m.getGroup(groupId);
+        }
+
+        private void setGroupName(final String name) {
+            updateGroup(UpdateGroup.newBuilder().withName(name).build());
+        }
+
+        private void setGroupDescription(final String description) {
+            updateGroup(UpdateGroup.newBuilder().withDescription(description).build());
+        }
+
+        private void setGroupAvatar(final String avatar) {
+            updateGroup(UpdateGroup.newBuilder().withAvatarFile(new File(avatar)).build());
+        }
+
+        private void setMessageExpirationTime(final int expirationTime) {
+            updateGroup(UpdateGroup.newBuilder().withExpirationTimer(expirationTime).build());
+        }
+
+        private void setGroupPermissionAddMember(final String permission) {
+            updateGroup(UpdateGroup.newBuilder().withAddMemberPermission(GroupPermission.valueOf(permission)).build());
+        }
+
+        private void setGroupPermissionEditDetails(final String permission) {
+            updateGroup(UpdateGroup.newBuilder()
+                    .withEditDetailsPermission(GroupPermission.valueOf(permission))
+                    .build());
+        }
+
+        private void setGroupPermissionSendMessage(final String permission) {
+            updateGroup(UpdateGroup.newBuilder()
+                    .withIsAnnouncementGroup(GroupPermission.valueOf(permission) == GroupPermission.ONLY_ADMINS)
+                    .build());
+        }
+
+        private void setIsBlocked(final boolean isBlocked) {
+            try {
+                m.setGroupBlocked(groupId, isBlocked);
+            } catch (NotMasterDeviceException e) {
+                throw new Error.Failure("This command doesn't work on linked devices.");
+            } catch (GroupNotFoundException e) {
+                throw new Error.GroupNotFound(e.getMessage());
+            } catch (IOException e) {
+                throw new Error.Failure(e.getMessage());
+            }
+        }
+
+        private void updateGroup(final UpdateGroup updateGroup) {
+            try {
+                m.updateGroup(groupId, updateGroup);
+            } catch (IOException e) {
+                throw new Error.Failure(e.getMessage());
+            } catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
+                throw new Error.GroupNotFound(e.getMessage());
+            } catch (AttachmentInvalidException e) {
+                throw new Error.AttachmentInvalid(e.getMessage());
+            }
+        }
+    }
 }