rudimentary XEP-0490 implementation

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java          |  22 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java    |  83 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java          |  22 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           |  15 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java | 200 
src/main/java/eu/siacs/conversations/xml/Namespace.java                  |   3 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java            |  12 
src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java        |   1 
8 files changed, 287 insertions(+), 71 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 
 import org.json.JSONArray;
@@ -437,6 +438,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return null;
     }
 
+    public Message findReceivedWithRemoteId(final String id) {
+        synchronized (this.messages) {
+            for (final Message message : this.messages) {
+                if (message.getStatus() == Message.STATUS_RECEIVED && id.equals(message.getRemoteMsgId())) {
+                    return message;
+                }
+            }
+        }
+        return null;
+    }
+
     public Message findMessageWithServerMsgId(String id) {
         synchronized (this.messages) {
             for (Message message : this.messages) {
@@ -576,20 +588,20 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
     }
 
-    public List<Message> markRead(String upToUuid) {
-        final List<Message> unread = new ArrayList<>();
+    public List<Message> markRead(final String upToUuid) {
+        final ImmutableList.Builder<Message> unread = new ImmutableList.Builder<>();
         synchronized (this.messages) {
-            for (Message message : this.messages) {
+            for (final Message message : this.messages) {
                 if (!message.isRead()) {
                     message.markRead();
                     unread.add(message);
                 }
                 if (message.getUuid().equals(upToUuid)) {
-                    return unread;
+                    return unread.build();
                 }
             }
         }
-        return unread;
+        return unread.build();
     }
 
     public Message getLatestMessage() {

src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java 🔗

@@ -2,6 +2,15 @@ package eu.siacs.conversations.generator;
 
 import android.util.Base64;
 
+import eu.siacs.conversations.BuildConfig;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.AxolotlService;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.XmppConnection;
+
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.text.SimpleDateFormat;
@@ -12,54 +21,42 @@ import java.util.List;
 import java.util.Locale;
 import java.util.TimeZone;
 
-import eu.siacs.conversations.BuildConfig;
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.utils.PhoneHelper;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.XmppConnection;
-import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
-
 public abstract class AbstractGenerator {
-    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+    private static final SimpleDateFormat DATE_FORMAT =
+            new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
     private final String[] FEATURES = {
-            Namespace.JINGLE,
-            Namespace.JINGLE_APPS_FILE_TRANSFER,
-            Namespace.JINGLE_TRANSPORTS_S5B,
-            Namespace.JINGLE_TRANSPORTS_IBB,
-            Namespace.JINGLE_ENCRYPTED_TRANSPORT,
-            Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
-            "http://jabber.org/protocol/muc",
-            "jabber:x:conference",
-            Namespace.OOB,
-            "http://jabber.org/protocol/caps",
-            "http://jabber.org/protocol/disco#info",
-            "urn:xmpp:avatar:metadata+notify",
-            Namespace.NICK + "+notify",
-            "urn:xmpp:ping",
-            "jabber:iq:version",
-            "http://jabber.org/protocol/chatstates"
+        Namespace.JINGLE,
+        Namespace.JINGLE_APPS_FILE_TRANSFER,
+        Namespace.JINGLE_TRANSPORTS_S5B,
+        Namespace.JINGLE_TRANSPORTS_IBB,
+        Namespace.JINGLE_ENCRYPTED_TRANSPORT,
+        Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
+        "http://jabber.org/protocol/muc",
+        "jabber:x:conference",
+        Namespace.OOB,
+        "http://jabber.org/protocol/caps",
+        "http://jabber.org/protocol/disco#info",
+        "urn:xmpp:avatar:metadata+notify",
+        Namespace.NICK + "+notify",
+        "urn:xmpp:ping",
+        "jabber:iq:version",
+        "http://jabber.org/protocol/chatstates",
+        Namespace.MDS_DISPLAYED + "+notify"
     };
     private final String[] MESSAGE_CONFIRMATION_FEATURES = {
-            "urn:xmpp:chat-markers:0",
-            "urn:xmpp:receipts"
-    };
-    private final String[] MESSAGE_CORRECTION_FEATURES = {
-            "urn:xmpp:message-correct:0"
+        "urn:xmpp:chat-markers:0", "urn:xmpp:receipts"
     };
+    private final String[] MESSAGE_CORRECTION_FEATURES = {"urn:xmpp:message-correct:0"};
     private final String[] PRIVACY_SENSITIVE = {
-            "urn:xmpp:time" //XEP-0202: Entity Time leaks time zone
+        "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
     };
     private final String[] VOIP_NAMESPACES = {
-            Namespace.JINGLE_TRANSPORT_ICE_UDP,
-            Namespace.JINGLE_FEATURE_AUDIO,
-            Namespace.JINGLE_FEATURE_VIDEO,
-            Namespace.JINGLE_APPS_RTP,
-            Namespace.JINGLE_APPS_DTLS,
-            Namespace.JINGLE_MESSAGE
+        Namespace.JINGLE_TRANSPORT_ICE_UDP,
+        Namespace.JINGLE_FEATURE_AUDIO,
+        Namespace.JINGLE_FEATURE_VIDEO,
+        Namespace.JINGLE_APPS_RTP,
+        Namespace.JINGLE_APPS_DTLS,
+        Namespace.JINGLE_MESSAGE
     };
     protected XmppConnectionService mXmppConnectionService;
 
@@ -90,7 +87,11 @@ public abstract class AbstractGenerator {
 
     String getCapHash(final Account account) {
         StringBuilder s = new StringBuilder();
-        s.append("client/").append(getIdentityType()).append("//").append(getIdentityName()).append('<');
+        s.append("client/")
+                .append(getIdentityType())
+                .append("//")
+                .append(getIdentityName())
+                .append('<');
         MessageDigest md;
         try {
             md = MessageDigest.getInstance("SHA-1");

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -129,6 +129,10 @@ public class IqGenerator extends AbstractGenerator {
         return retrieve(Namespace.BOOKMARKS2, null);
     }
 
+    public IqPacket retrieveMds() {
+        return retrieve(Namespace.MDS_DISPLAYED, null);
+    }
+
     public IqPacket publishNick(String nick) {
         final Element item = new Element("item");
         item.setAttribute("id", "current");
@@ -264,6 +268,24 @@ public class IqGenerator extends AbstractGenerator {
         return conference;
     }
 
+    public Element mdsDisplayed(final String stanzaId, final Conversation conversation) {
+        final Jid by;
+        if (conversation.getMode() == Conversation.MODE_MULTI) {
+            by = conversation.getJid().asBareJid();
+        } else {
+            by = conversation.getAccount().getJid().asBareJid();
+        }
+        return mdsDisplayed(stanzaId, by);
+    }
+
+    private Element mdsDisplayed(final String stanzaId, final Jid by) {
+        final Element displayed = new Element("displayed", Namespace.MDS_DISPLAYED);
+        final Element stanzaIdElement = displayed.addChild("stanza-id", Namespace.STANZA_IDS);
+        stanzaIdElement.setAttribute("id", stanzaId);
+        stanzaIdElement.setAttribute("by", by);
+        return displayed;
+    }
+
     public IqPacket publishBundles(final SignedPreKeyRecord signedPreKeyRecord, final IdentityKey identityKey,
                                    final Set<PreKeyRecord> preKeyRecords, final int deviceId, Bundle publishOptions) {
         final Element item = new Element("item");

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -271,6 +271,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                     mXmppConnectionService.updateConversationUi();
                 }
             }
+        } else if (Namespace.MDS_DISPLAYED.equals(node) && account.getJid().asBareJid().equals(from)) {
+            final Element item = items.findChild("item");
+            mXmppConnectionService.processMdsItem(account, item);
         } else {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + " received pubsub notification for node=" + node);
         }
@@ -985,12 +988,18 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                 }
             }
         }
-        Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
+        final Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
         if (displayed != null) {
             final String id = displayed.getAttribute("id");
             final Jid sender = InvalidJid.getNullForInvalid(displayed.getAttributeAsJid("sender"));
             if (packet.fromAccount(account) && !selfAddressed) {
-                dismissNotification(account, counterpart, query, id);
+                final Conversation c =
+                        mXmppConnectionService.find(account, counterpart.asBareJid());
+                final Message message =
+                        (c == null || id == null) ? null : c.findReceivedWithRemoteId(id);
+                if (message != null && (query == null || query.isCatchup())) {
+                    mXmppConnectionService.markReadUpTo(c, message);
+                }
                 if (query == null) {
                     activateGracePeriod(account);
                 }
@@ -1012,7 +1021,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                     final boolean trueJidMatchesAccount = account.getJid().asBareJid().equals(trueJid == null ? null : trueJid.asBareJid());
                     if (trueJidMatchesAccount || conversation.getMucOptions().isSelf(counterpart)) {
                         if (!message.isRead() && (query == null || query.isCatchup())) { //checking if message is unread fixes race conditions with reflections
-                            mXmppConnectionService.markRead(conversation);
+                            mXmppConnectionService.markReadUpTo(conversation, message);
                         }
                     } else if (!counterpart.isBareJid() && trueJid != null) {
                         final ReadByMarker readByMarker = ReadByMarker.from(counterpart, trueJid);

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -49,6 +49,7 @@ import android.util.Pair;
 import androidx.annotation.BoolRes;
 import androidx.annotation.IntegerRes;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.core.app.RemoteInput;
 import androidx.core.content.ContextCompat;
 import androidx.core.util.Consumer;
@@ -56,6 +57,8 @@ import androidx.core.util.Consumer;
 import com.google.common.base.Objects;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Iterables;
 
 import org.conscrypt.Conscrypt;
 import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep;
@@ -152,6 +155,7 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.LocalizedContent;
 import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.InvalidJid;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnBindListener;
 import eu.siacs.conversations.xmpp.OnContactStatusChanged;
@@ -368,6 +372,12 @@ public class XmppConnectionService extends Service {
             } else if (!account.getXmppConnection().getFeatures().bookmarksConversion()) {
                 fetchBookmarks(account);
             }
+
+            if (connection.getFeatures().mds()) {
+                fetchMessageDisplayedSynchronization(account);
+            } else {
+                Log.d(Config.LOGTAG,account.getJid()+": server has no support for mds");
+            }
             final boolean flexible = account.getXmppConnection().getFeatures().flexibleOfflineMessageRetrieval();
             final boolean catchup = getMessageArchiveService().inCatchup(account);
             final boolean trackOfflineMessageRetrieval;
@@ -392,6 +402,7 @@ public class XmppConnectionService extends Service {
             unifiedPushBroker.renewUnifiedPushEndpointsOnBind(account);
         }
     };
+
     private final AtomicLong mLastExpiryRun = new AtomicLong(0);
     private final LruCache<Pair<String, String>, ServiceDiscoveryResult> discoCache = new LruCache<>(20);
     private final OnStatusChanged statusListener = new OnStatusChanged() {
@@ -1902,18 +1913,88 @@ public class XmppConnectionService extends Service {
 
     public void fetchBookmarks2(final Account account) {
         final IqPacket retrieve = mIqGenerator.retrieveBookmarks();
-        sendIqPacket(account, retrieve, new OnIqPacketReceived() {
-            @Override
-            public void onIqPacketReceived(final Account account, final IqPacket response) {
-                if (response.getType() == IqPacket.TYPE.RESULT) {
-                    final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB);
-                    final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromPubsub(pubsub, account);
-                    processBookmarksInitial(account, bookmarks, true);
-                }
+        sendIqPacket(account, retrieve, (a, response) -> {
+            if (response.getType() == IqPacket.TYPE.RESULT) {
+                final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB);
+                final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromPubsub(pubsub, a);
+                processBookmarksInitial(a, bookmarks, true);
             }
         });
     }
 
+    private void fetchMessageDisplayedSynchronization(final Account account) {
+        Log.d(Config.LOGTAG, account.getJid() + ": retrieve mds");
+        final var retrieve = mIqGenerator.retrieveMds();
+        sendIqPacket(
+                account,
+                retrieve,
+                (a, response) -> {
+                    if (response.getType() != IqPacket.TYPE.RESULT) {
+                        return;
+                    }
+                    final var pubSub = response.findChild("pubsub", Namespace.PUBSUB);
+                    final Element items = pubSub == null ? null : pubSub.findChild("items");
+                    if (items == null
+                            || !Namespace.MDS_DISPLAYED.equals(items.getAttribute("node"))) {
+                        return;
+                    }
+                    for (final Element child : items.getChildren()) {
+                        if ("item".equals(child.getName())) {
+                            processMdsItem(account, child);
+                        }
+                    }
+                });
+    }
+
+    public void processMdsItem(final Account account, final Element item) {
+        final Jid jid =
+                item == null ? null : InvalidJid.getNullForInvalid(item.getAttributeAsJid("id"));
+        if (jid == null) {
+            return;
+        }
+        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": processing mds item for " + jid);
+        final Element displayed = item.findChild("displayed", Namespace.MDS_DISPLAYED);
+        final Element stanzaId =
+                displayed == null ? null : displayed.findChild("stanza-id", Namespace.STANZA_IDS);
+        final String id = stanzaId == null ? null : stanzaId.getAttribute("id");
+        final Conversation conversation = find(account, jid);
+        if (id != null && conversation != null) {
+            markReadUpToStanzaId(conversation, id);
+        }
+    }
+
+    public void markReadUpToStanzaId(final Conversation conversation, final String stanzaId) {
+        final Message message = conversation.findMessageWithServerMsgId(stanzaId);
+        if (message == null) { // do we want to check if isRead?
+            return;
+        }
+        markReadUpTo(conversation, message);
+    }
+
+    public void markReadUpTo(final Conversation conversation, final Message message) {
+        final boolean isDismissNotification = isDismissNotification(message);
+        final var uuid = message.getUuid();
+        Log.d(
+                Config.LOGTAG,
+                conversation.getAccount().getJid().asBareJid()
+                        + ": mark "
+                        + conversation.getJid().asBareJid()
+                        + " as read up to "
+                        + uuid);
+        markRead(conversation, uuid, isDismissNotification);
+    }
+
+    private static boolean isDismissNotification(final Message message) {
+        Message next = message.next();
+        while (next != null) {
+            if (message.getStatus() == Message.STATUS_RECEIVED) {
+                return false;
+            }
+            next = next.next();
+        }
+        return true;
+    }
+
     public void processBookmarksInitial(Account account, Map<Jid, Bookmark> bookmarks, final boolean pep) {
         final Set<Jid> previousBookmarks = account.getBookmarkedJids();
         final boolean synchronizeWithBookmarks = synchronizeWithBookmarks();
@@ -2050,7 +2131,7 @@ public class XmppConnectionService extends Service {
                     }
                 });
             } else {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error publishing bookmarks (retry=" + retry + ") " + response);
+                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error publishing "+node+" (retry=" + retry + ") " + response);
             }
         });
     }
@@ -4534,22 +4615,99 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void sendReadMarker(final Conversation conversation, String upToUuid) {
-        final boolean isPrivateAndNonAnonymousMuc = conversation.getMode() == Conversation.MODE_MULTI && conversation.isPrivateAndNonAnonymous();
+    public void sendReadMarker(final Conversation conversation, final String upToUuid) {
+        final boolean isPrivateAndNonAnonymousMuc =
+                conversation.getMode() == Conversation.MODE_MULTI
+                        && conversation.isPrivateAndNonAnonymous();
         final List<Message> readMessages = this.markRead(conversation, upToUuid, true);
-        if (readMessages.size() > 0) {
-            updateConversationUi();
+        if (readMessages.isEmpty()) {
+            return;
         }
-        final Message markable = Conversation.getLatestMarkableMessage(readMessages, isPrivateAndNonAnonymousMuc);
-        if (confirmMessages()
-                && markable != null
-                && (markable.trusted() || isPrivateAndNonAnonymousMuc)
-                && markable.getRemoteMsgId() != null) {
-            Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": sending read marker to " + markable.getCounterpart().toString());
-            final Account account = conversation.getAccount();
-            final MessagePacket packet = mMessageGenerator.confirm(markable);
+        final var account = conversation.getAccount();
+        final var connection = account.getXmppConnection();
+        updateConversationUi();
+        final var last =
+                Iterables.getLast(
+                        Collections2.filter(
+                                readMessages,
+                                m ->
+                                        !m.isPrivateMessage()
+                                                && m.getStatus() == Message.STATUS_RECEIVED),
+                        null);
+        if (last == null) {
+            return;
+        }
+
+        final boolean sendDisplayedMarker =
+                confirmMessages()
+                        && (last.trusted() || isPrivateAndNonAnonymousMuc)
+                        && last.getRemoteMsgId() != null
+                        && (last.markable || isPrivateAndNonAnonymousMuc);
+        final boolean serverAssist =
+                connection != null && connection.getFeatures().mdsServerAssist();
+
+        final String stanzaId = last.getServerMsgId();
+
+        if (sendDisplayedMarker && serverAssist) {
+            final var mdsDisplayed = mIqGenerator.mdsDisplayed(stanzaId, conversation);
+            final MessagePacket packet = mMessageGenerator.confirm(last);
+            packet.addChild(mdsDisplayed);
+            if (!last.isPrivateMessage()) {
+                packet.setTo(packet.getTo().asBareJid());
+            }
+            Log.d(Config.LOGTAG,account.getJid().asBareJid()+": server assisted "+packet);
             this.sendMessagePacket(account, packet);
+        } else {
+            publishMds(last);
+            // read markers will be sent after MDS to flush the CSI stanza queue
+            if (sendDisplayedMarker) {
+                Log.d(
+                        Config.LOGTAG,
+                        conversation.getAccount().getJid().asBareJid()
+                                + ": sending displayed marker to "
+                                + last.getCounterpart().toString());
+                final MessagePacket packet = mMessageGenerator.confirm(last);
+                this.sendMessagePacket(account, packet);
+            }
+        }
+    }
+
+    private void publishMds(@Nullable final Message message) {
+        final String stanzaId = message == null ? null : message.getServerMsgId();
+        if (Strings.isNullOrEmpty(stanzaId)) {
+            return;
+        }
+        final Conversation conversation;
+        final var conversational = message.getConversation();
+        if (conversational instanceof Conversation c) {
+            conversation = c;
+        } else {
+            return;
+        }
+        final var account = conversation.getAccount();
+        final var connection = account.getXmppConnection();
+        if (connection == null || !connection.getFeatures().mds()) {
+            return;
         }
+        final Jid itemId;
+        if (message.isPrivateMessage()) {
+            itemId = message.getCounterpart();
+        } else {
+            itemId = conversation.getJid().asBareJid();
+        }
+        Log.d(Config.LOGTAG,"publishing mds for "+itemId+"/"+stanzaId);
+        publishMds(account, itemId, stanzaId, conversation);
+    }
+
+    private void publishMds(
+            final Account account, final Jid itemId, final String stanzaId, final Conversation conversation) {
+        final var item = mIqGenerator.mdsDisplayed(stanzaId, conversation);
+        pushNodeAndEnforcePublishOptions(
+                account,
+                Namespace.MDS_DISPLAYED,
+                item,
+                itemId.toEscapedString(),
+                PublishOptions.persistentWhitelistAccessMaxItems());
     }
 
     public MemorizingTrustManager getMemorizingTrustManager() {

src/main/java/eu/siacs/conversations/xml/Namespace.java 🔗

@@ -24,6 +24,7 @@ public final class Namespace {
     public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
     public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
     public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
+    public static final String PUBSUB_CONFIG_NODE_MAX = PUBSUB + "#config-node-max";
     public static final String PUBSUB_ERROR = PUBSUB + "#errors";
     public static final String PUBSUB_OWNER = PUBSUB + "#owner";
     public static final String NICK = "http://jabber.org/protocol/nick";
@@ -76,4 +77,6 @@ public final class Namespace {
     public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam";
     public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264";
     public static final String HASHES = "urn:xmpp:hashes:2";
+    public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0";
+    public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0";
 }

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -2968,6 +2968,10 @@ public class XmppConnection implements Runnable {
             return hasDiscoFeature(account.getJid().asBareJid(), Namespace.PUBSUB_PUBLISH_OPTIONS);
         }
 
+        public boolean pepConfigNodeMax() {
+            return hasDiscoFeature(account.getJid().asBareJid(), Namespace.PUBSUB_CONFIG_NODE_MAX);
+        }
+
         public boolean pepOmemoWhitelisted() {
             return hasDiscoFeature(
                     account.getJid().asBareJid(), AxolotlService.PEP_OMEMO_WHITELISTED);
@@ -3068,5 +3072,13 @@ public class XmppConnection implements Runnable {
         public boolean externalServiceDiscovery() {
             return hasDiscoFeature(account.getDomain(), Namespace.EXTERNAL_SERVICE_DISCOVERY);
         }
+
+        public boolean mds() {
+            return pepPublishOptions() && pepConfigNodeMax();
+        }
+
+        public boolean mdsServerAssist() {
+            return hasDiscoFeature(account.getJid().asBareJid(), Namespace.MDS_DISPLAYED);
+        }
     }
 }

src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java 🔗

@@ -31,7 +31,6 @@ public class PublishOptions {
         options.putString("pubsub#access_model", "whitelist");
         options.putString("pubsub#send_last_published_item", "never");
         options.putString("pubsub#max_items", "max");
-
         options.putString("pubsub#notify_delete", "true");
         options.putString("pubsub#notify_retract", "true"); //one could also set notify=true on the retract