Merge commit '12df7f1a17c5cfa0ea1607dde6705b0773d6ffd6'

Stephen Paul Weber created

* commit '12df7f1a17c5cfa0ea1607dde6705b0773d6ffd6':
  introduce ping and carbons managers
  clean caps cache when resources go unavailable
  respond to version requests via DiscoManager
  refactor channel discovery to use new APIs
  respond to disco#info queries via DiscoManager

Change summary

src/main/java/eu/siacs/conversations/AppSettings.java                      |  12 
src/main/java/eu/siacs/conversations/Config.java                           |   2 
src/main/java/eu/siacs/conversations/entities/Room.java                    |  52 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java      | 116 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java            |  62 
src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java      |  28 
src/main/java/eu/siacs/conversations/parser/AbstractParser.java            |   8 
src/main/java/eu/siacs/conversations/parser/IqParser.java                  |  59 
src/main/java/eu/siacs/conversations/parser/MessageParser.java             |  25 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java            |  26 
src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java | 185 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java   |  59 
src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java      |   5 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java          |  51 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java           |   6 
src/main/java/eu/siacs/conversations/xmpp/Managers.java                    |  28 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java              | 127 
src/main/java/eu/siacs/conversations/xmpp/manager/CarbonsManager.java      |  62 
src/main/java/eu/siacs/conversations/xmpp/manager/DiscoManager.java        | 183 
src/main/java/eu/siacs/conversations/xmpp/manager/PingManager.java         |  48 
src/main/java/eu/siacs/conversations/xmpp/manager/PresenceManager.java     |  59 
src/main/java/im/conversations/android/xmpp/ServiceDescription.java        |  49 
src/main/java/im/conversations/android/xmpp/model/data/Data.java           |   5 
src/main/java/im/conversations/android/xmpp/model/stanza/Iq.java           |  10 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java   |   9 
src/main/res/values-sw600dp/defaults.xml                                   |   3 
src/main/res/values-sw600dp/device.xml                                     |   4 
src/main/res/values/defaults.xml                                           |   1 
src/main/res/values/device.xml                                             |   4 
29 files changed, 828 insertions(+), 460 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/AppSettings.java πŸ”—

@@ -135,6 +135,18 @@ public class AppSettings {
         return getBooleanPreference(ALIGN_START, R.bool.align_start);
     }
 
+    public boolean isConfirmMessages() {
+        return getBooleanPreference(CONFIRM_MESSAGES, R.bool.confirm_messages);
+    }
+
+    public boolean isAllowMessageCorrection() {
+        return getBooleanPreference(ALLOW_MESSAGE_CORRECTION, R.bool.allow_message_correction);
+    }
+
+    public boolean isBroadcastLastActivity() {
+        return getBooleanPreference(BROADCAST_LAST_ACTIVITY, R.bool.last_activity);
+    }
+
     public boolean isUseTor() {
         return QuickConversationsService.isConversations()
                 && getBooleanPreference(USE_TOR, R.bool.use_tor);

src/main/java/eu/siacs/conversations/Config.java πŸ”—

@@ -119,6 +119,8 @@ public final class Config {
     public static final boolean USE_DIRECT_JINGLE_CANDIDATES = true;
     public static final boolean USE_JINGLE_MESSAGE_INIT = true;
 
+    public static final boolean ENABLE_CAPS_CACHE = true;
+
     public static final boolean DISABLE_HTTP_UPLOAD = false;
     public static final boolean EXTENDED_SM_LOGGING = true; // log stanza counts
     public static final boolean BACKGROUND_STANZA_LOGGING =

src/main/java/eu/siacs/conversations/entities/Room.java πŸ”—

@@ -3,31 +3,42 @@ package eu.siacs.conversations.entities;
 import com.google.common.base.Objects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.Iterables;
+import com.google.common.primitives.Ints;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.utils.LanguageUtils;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 
 public class Room implements AvatarService.Avatarable, Comparable<Room> {
 
-    public String address;
-    public String name;
-    public String description;
-    public String language;
-    public int nusers;
+    public final String address;
+    public final String name;
+    public final String description;
+    public final String language;
+    public final int numberOfUsers;
 
-    public Room(String address, String name, String description, String language, int nusers) {
+    public Room(
+            final String address,
+            final String name,
+            final String description,
+            final String language,
+            final Integer numberOfUsers) {
         this.address = address;
         this.name = name;
         this.description = description;
         this.language = language;
-        this.nusers = nusers;
+        this.numberOfUsers = numberOfUsers == null ? 0 : numberOfUsers;
     }
 
-    public Room() {}
-
     public String getName() {
-        return name;
+        if (Strings.isNullOrEmpty(name)) {
+            final var jid = Jid.ofOrInvalid(address);
+            return jid.getLocal();
+        } else {
+            return name;
+        }
     }
 
     public String getDescription() {
@@ -81,9 +92,28 @@ public class Room implements AvatarService.Avatarable, Comparable<Room> {
     @Override
     public int compareTo(Room o) {
         return ComparisonChain.start()
-                .compare(o.nusers, nusers)
+                .compare(o.numberOfUsers, numberOfUsers)
                 .compare(Strings.nullToEmpty(name), Strings.nullToEmpty(o.name))
                 .compare(Strings.nullToEmpty(address), Strings.nullToEmpty(o.address))
                 .result();
     }
+
+    public static Room of(final Jid address, InfoQuery query) {
+        final var identity = Iterables.getFirst(query.getIdentities(), null);
+        final var ri =
+                query.getServiceDiscoveryExtension("http://jabber.org/protocol/muc#roominfo");
+        final String name = identity == null ? null : identity.getIdentityName();
+        String roomName = ri == null ? null : ri.getValue("muc#roomconfig_roomname");
+        String description = ri == null ? null : ri.getValue("muc#roominfo_description");
+        String language = ri == null ? null : ri.getValue("muc#roominfo_lang");
+        String occupants = ri == null ? null : ri.getValue("muc#roominfo_occupants");
+        final Integer numberOfUsers = Ints.tryParse(Strings.nullToEmpty(occupants));
+
+        return new Room(
+                address.toString(),
+                Strings.isNullOrEmpty(roomName) ? name : roomName,
+                description,
+                language,
+                numberOfUsers);
+    }
 }

src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java πŸ”—

@@ -1,61 +1,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;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
 import java.util.Locale;
 import java.util.TimeZone;
 
 public abstract class AbstractGenerator {
     private static final SimpleDateFormat DATE_FORMAT =
             new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
-    private final String[] STATIC_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.REACTIONS
-    };
-    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"};
-    private final String[] PRIVACY_SENSITIVE = {
-        "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
-    };
+
     protected XmppConnectionService mXmppConnectionService;
 
     AbstractGenerator(XmppConnectionService service) {
@@ -70,72 +24,4 @@ public abstract class AbstractGenerator {
     String getIdentityVersion() {
         return BuildConfig.VERSION_NAME;
     }
-
-    String getIdentityName() {
-        return BuildConfig.APP_NAME;
-    }
-
-    String getIdentityType() {
-        if ("chromium".equals(android.os.Build.BRAND)) {
-            return "pc";
-        } else {
-            return mXmppConnectionService.getString(R.string.default_resource).toLowerCase();
-        }
-    }
-
-    String getCapHash(final Account account) {
-        StringBuilder s = new StringBuilder();
-        s.append("client/")
-                .append(getIdentityType())
-                .append("//")
-                .append(getIdentityName())
-                .append('<');
-        MessageDigest md;
-        try {
-            md = MessageDigest.getInstance("SHA-1");
-        } catch (NoSuchAlgorithmException e) {
-            return null;
-        }
-
-        for (String feature : getFeatures(account)) {
-            s.append(feature).append('<');
-        }
-        final byte[] sha1 = md.digest(s.toString().getBytes());
-        return Base64.encodeToString(sha1, Base64.NO_WRAP);
-    }
-
-    public List<String> getFeatures(final Account account) {
-        final XmppConnection connection = account.getXmppConnection();
-        final ArrayList<String> features = new ArrayList<>(Arrays.asList(STATIC_FEATURES));
-        features.add("http://jabber.org/protocol/xhtml-im");
-        features.add("urn:xmpp:bob");
-        if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
-            features.add(Namespace.MDS_DISPLAYED + "+notify");
-        }
-        if (mXmppConnectionService.confirmMessages()) {
-            features.addAll(Arrays.asList(MESSAGE_CONFIRMATION_FEATURES));
-        }
-        if (mXmppConnectionService.allowMessageCorrection()) {
-            features.addAll(Arrays.asList(MESSAGE_CORRECTION_FEATURES));
-        }
-        if (Config.supportOmemo()) {
-            features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
-        }
-        if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) {
-            features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
-            features.addAll(Arrays.asList(VOIP_NAMESPACES));
-            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
-        }
-        if (mXmppConnectionService.broadcastLastActivity()) {
-            features.add(Namespace.IDLE);
-        }
-        if (connection != null && connection.getFeatures().bookmarks2()) {
-            features.add(Namespace.BOOKMARKS2 + "+notify");
-        } else {
-            features.add(Namespace.BOOKMARKS + "+notify");
-        }
-
-        Collections.sort(features);
-        return features;
-    }
 }

src/main/java/eu/siacs/conversations/generator/IqGenerator.java πŸ”—

@@ -19,7 +19,6 @@ import java.io.ByteArrayOutputStream;
 import java.io.FileInputStream;
 import java.io.IOException;
 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.entities.Bookmark;
@@ -70,46 +69,6 @@ public class IqGenerator extends AbstractGenerator {
         super(service);
     }
 
-    public Iq discoResponse(final Account account, final Iq request) {
-        final var packet = new Iq(Iq.Type.RESULT);
-        packet.setId(request.getId());
-        packet.setTo(request.getFrom());
-        final Element query = packet.addChild("query", "http://jabber.org/protocol/disco#info");
-        query.setAttribute("node", request.query().getAttribute("node"));
-        final Element identity = query.addChild("identity");
-        identity.setAttribute("category", "client");
-        identity.setAttribute("type", getIdentityType());
-        identity.setAttribute("name", getIdentityName());
-        for (final String feature : getFeatures(account)) {
-            query.addChild("feature").setAttribute("var", feature);
-        }
-        return packet;
-    }
-
-    public Iq versionResponse(final Iq request) {
-        final var packet = request.generateResponse(Iq.Type.RESULT);
-        Element query = packet.query("jabber:iq:version");
-        query.addChild("name").setContent(mXmppConnectionService.getString(R.string.app_name));
-        query.addChild("version").setContent(getIdentityVersion());
-        final StringBuilder os = new StringBuilder();
-        if ("chromium".equals(android.os.Build.BRAND)) {
-            os.append("Chrome OS");
-        } else {
-            os.append("Android");
-        }
-        os.append(" ");
-        os.append(android.os.Build.VERSION.RELEASE);
-        if (QuickConversationsService.isPlayStoreFlavor()) {
-            os.append(" (");
-            os.append(android.os.Build.BOARD);
-            os.append(", ");
-            os.append(android.os.Build.FINGERPRINT);
-            os.append(")");
-            query.addChild("os").setContent(os.toString());
-        }
-        return packet;
-    }
-
     public Iq entityTimeResponse(final Iq request) {
         final Iq packet = request.generateResponse(Iq.Type.RESULT);
         Element time = packet.addChild("time", "urn:xmpp:time");
@@ -665,27 +624,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq queryDiscoItems(final Jid jid) {
-        final Iq packet = new Iq(Iq.Type.GET);
-        packet.setTo(jid);
-        packet.query(Namespace.DISCO_ITEMS);
-        return packet;
-    }
-
-    public Iq queryDiscoItems(Jid jid, String node) {
-        final var packet = queryDiscoItems(jid);
-        final var query = packet.query(Namespace.DISCO_ITEMS);
-        query.setAttribute("node", node);
-        return packet;
-    }
-
-    public Iq queryDiscoInfo(final Jid jid) {
-        final Iq packet = new Iq(Iq.Type.GET);
-        packet.setTo(jid);
-        packet.addChild("query", Namespace.DISCO_INFO);
-        return packet;
-    }
-
     public Iq bobResponse(Iq request) {
         try {
             final var bobCid = request.findChild("data", "urn:xmpp:bob").getAttribute("cid");

src/main/java/eu/siacs/conversations/generator/PresenceGenerator.java πŸ”—

@@ -5,8 +5,8 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import im.conversations.android.xmpp.model.stanza.Presence;
 
 public class PresenceGenerator extends AbstractGenerator {
@@ -63,29 +63,17 @@ public class PresenceGenerator extends AbstractGenerator {
         return selfPresence(account, status, true, null);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(final Account account, final Presence.Availability status, final boolean personal, final String nickname) {
-        final im.conversations.android.xmpp.model.stanza.Presence packet =
-                new im.conversations.android.xmpp.model.stanza.Presence();
-        if (personal) {
-            final String sig = account.getPgpSignature();
-            final String message = account.getPresenceStatusMessage();
-            packet.setAvailability(status);
-            packet.setStatus(message);
-            if (sig != null && mXmppConnectionService.getPgpEngine() != null) {
-                packet.addChild("x", "jabber:x:signed").setContent(sig);
-            }
+    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(
+            final Account account, final Presence.Availability status, final boolean personal, final String nickname) {
+        final var connection = account.getXmppConnection();
+        if (connection == null) {
+            return new Presence();
         }
+        final var packet = connection.getManager(PresenceManager.class).getPresence(status, personal);
         if (nickname != null) {
-            Element nick = packet.addChild("nick", "http://jabber.org/protocol/nick");
+            final var nick = packet.addChild("nick", "http://jabber.org/protocol/nick");
             nick.setContent(nickname);
         }
-        final String capHash = getCapHash(account);
-        if (capHash != null) {
-            Element cap = packet.addChild("c", "http://jabber.org/protocol/caps");
-            cap.setAttribute("hash", "sha-1");
-            cap.setAttribute("node", "https://cheogram.com");
-            cap.setAttribute("ver", capHash);
-        }
         return packet;
     }
 

src/main/java/eu/siacs/conversations/parser/AbstractParser.java πŸ”—

@@ -16,16 +16,16 @@ import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
 import im.conversations.android.xmpp.model.stanza.Stanza;
 
-public abstract class AbstractParser {
+public abstract class AbstractParser extends XmppConnection.Delegate {
 
     protected final XmppConnectionService mXmppConnectionService;
-    protected final Account account;
 
-    protected AbstractParser(final XmppConnectionService service, final Account account) {
+    protected AbstractParser(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
         this.mXmppConnectionService = service;
-        this.account = account;
     }
 
     public static Long parseTimestamp(Element element, Long d) {

src/main/java/eu/siacs/conversations/parser/IqParser.java πŸ”—

@@ -1,6 +1,5 @@
 package eu.siacs.conversations.parser;
 
-import android.text.TextUtils;
 import android.util.Log;
 import android.util.Pair;
 import androidx.annotation.NonNull;
@@ -10,14 +9,16 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
-import eu.siacs.conversations.entities.Room;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
-import eu.siacs.conversations.xmpp.forms.Data;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.version.Version;
 import java.io.ByteArrayInputStream;
 import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
@@ -53,8 +54,8 @@ import org.whispersystems.libsignal.state.PreKeyBundle;
 
 public class IqParser extends AbstractParser implements Consumer<Iq> {
 
-    public IqParser(final XmppConnectionService service, final Account account) {
-        super(service, account);
+    public IqParser(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
     }
 
     public static List<Jid> items(final Iq packet) {
@@ -74,38 +75,6 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
         return items;
     }
 
-    public static Room parseRoom(Iq packet) {
-        final Element query = packet.findChild("query", Namespace.DISCO_INFO);
-        if (query == null) {
-            return null;
-        }
-        final Element x = query.findChild("x");
-        if (x == null) {
-            return null;
-        }
-        final Element identity = query.findChild("identity");
-        Data data = Data.parse(x);
-        String address = packet.getFrom().toString();
-        String name = identity == null ? null : identity.getAttribute("name");
-        String roomName = data.getValue("muc#roomconfig_roomname");
-        String description = data.getValue("muc#roominfo_description");
-        String language = data.getValue("muc#roominfo_lang");
-        String occupants = data.getValue("muc#roominfo_occupants");
-        int nusers;
-        try {
-            nusers = occupants == null ? 0 : Integer.parseInt(occupants);
-        } catch (NumberFormatException e) {
-            nusers = 0;
-        }
-
-        return new Room(
-                address,
-                TextUtils.isEmpty(roomName) ? name : roomName,
-                description,
-                language,
-                nusers);
-    }
-
     private void rosterItems(final Account account, final Element query) {
         final String version = query.getAttribute("ver");
         if (version != null) {
@@ -427,6 +396,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
 
     @Override
     public void accept(final Iq packet) {
+        final var account = getAccount();
         final boolean isGet = packet.getType() == Iq.Type.GET;
         if (packet.getType() == Iq.Type.ERROR || packet.getType() == Iq.Type.TIMEOUT) {
             return;
@@ -454,7 +424,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
             // Otherwise, just update the existing blocklist.
             if (packet.getType() == Iq.Type.RESULT) {
                 account.clearBlocklist();
-                account.getXmppConnection().getFeatures().setBlockListRequested(true);
+                connection.getFeatures().setBlockListRequested(true);
             }
             if (items != null) {
                 final Collection<Jid> jids = new ArrayList<>(items.size());
@@ -514,13 +484,10 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
                 || packet.hasChild("data", "http://jabber.org/protocol/ibb")
                 || packet.hasChild("close", "http://jabber.org/protocol/ibb")) {
             mXmppConnectionService.getJingleConnectionManager().deliverIbbPacket(account, packet);
-        } else if (packet.hasChild("query", "http://jabber.org/protocol/disco#info")) {
-            final Iq response =
-                    mXmppConnectionService.getIqGenerator().discoResponse(account, packet);
-            mXmppConnectionService.sendIqPacket(account, response, null);
-        } else if (packet.hasChild("query", "jabber:iq:version") && isGet) {
-            final Iq response = mXmppConnectionService.getIqGenerator().versionResponse(packet);
-            mXmppConnectionService.sendIqPacket(account, response, null);
+        } else if (packet.hasExtension(InfoQuery.class) && isGet) {
+            this.getManager(DiscoManager.class).handleInfoQuery(packet);
+        } else if (packet.hasExtension(Version.class) && isGet) {
+            this.getManager(DiscoManager.class).handleVersionRequest(packet);
         } else if (packet.hasChild("ping", "urn:xmpp:ping") && isGet) {
             final Iq response = packet.generateResponse(Iq.Type.RESULT);
             mXmppConnectionService.sendIqPacket(account, response, null);
@@ -564,7 +531,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
                 final Element error = response.addChild("error");
                 error.setAttribute("type", "cancel");
                 error.addChild("feature-not-implemented", "urn:ietf:params:xml:ns:xmpp-stanzas");
-                account.getXmppConnection().sendIqPacket(response, null);
+                connection.sendIqPacket(response, null);
             }
         }
     }

src/main/java/eu/siacs/conversations/parser/MessageParser.java πŸ”—

@@ -61,6 +61,7 @@ import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.LocalizedContent;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
@@ -108,8 +109,8 @@ public class MessageParser extends AbstractParser
     private static final List<String> JINGLE_MESSAGE_ELEMENT_NAMES =
             Arrays.asList("accept", "propose", "proceed", "reject", "retract", "ringing", "finish");
 
-    public MessageParser(final XmppConnectionService service, final Account account) {
-        super(service, account);
+    public MessageParser(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
     }
 
     private static String extractStanzaId(
@@ -518,6 +519,7 @@ public class MessageParser extends AbstractParser
 
     @Override
     public void accept(final im.conversations.android.xmpp.model.stanza.Message original) {
+        final var account = connection.getAccount();
         if (handleErrorMessage(account, original)) {
             return;
         }
@@ -539,8 +541,7 @@ public class MessageParser extends AbstractParser
                 queryId == null
                         ? null
                         : mXmppConnectionService.getMessageArchiveService().findQuery(queryId);
-        final boolean offlineMessagesRetrieved =
-                account.getXmppConnection().isOfflineMessagesRetrieved();
+        final boolean offlineMessagesRetrieved = connection.isOfflineMessagesRetrieved();
         if (query != null && query.validFrom(original.getFrom())) {
             final var f = getForwardedMessagePacket(original, "result", query.version.namespace);
             if (f == null) {
@@ -756,7 +757,12 @@ public class MessageParser extends AbstractParser
             }
         }
 
-        final boolean conversationIsProbablyMuc = isTypeGroupChat || mucUserElement != null || account.getXmppConnection().getMucServersWithholdAccount().contains(counterpart.getDomain().toString());
+        final boolean conversationIsProbablyMuc =
+                isTypeGroupChat
+                        || mucUserElement != null
+                        || connection
+                                .getMucServersWithholdAccount()
+                                .contains(counterpart.getDomain().toString());
         final Element webxdc = packet.findChild("x", "urn:xmpp:webxdc:0");
         final Element thread = packet.findChild("thread");
         if (webxdc != null && thread != null) {
@@ -1638,6 +1644,7 @@ public class MessageParser extends AbstractParser
             final im.conversations.android.xmpp.model.stanza.Message packet,
             final MessageArchiveService.Query query,
             final Jid from) {
+        final var account = this.connection.getAccount();
         final var id = received.getId();
         if (packet.fromAccount(account)) {
             if (query != null && id != null && packet.getTo() != null) {
@@ -1668,9 +1675,10 @@ public class MessageParser extends AbstractParser
             final Jid counterpart,
             final MessageArchiveService.Query query,
             final boolean isTypeGroupChat,
-            Conversation conversation,
-            Element mucUserElement,
-            Jid from) {
+            final Conversation conversation,
+            final Element mucUserElement,
+            final Jid from) {
+        final var account = getAccount();
         final var id = displayed.getId();
         // TODO we don’t even use 'sender' any more. Remove this!
         final Jid sender = Jid.Invalid.getNullForInvalid(displayed.getAttributeAsJid("sender"));
@@ -1759,6 +1767,7 @@ public class MessageParser extends AbstractParser
             final Jid mucTrueCounterPart,
             final int status,
             final im.conversations.android.xmpp.model.stanza.Message packet) {
+        final var account = getAccount();
         final String reactingTo = reactions.getId();
         if (conversation != null && reactingTo != null) {
             if (isTypeGroupChat && conversation.getMode() == Conversational.MODE_MULTI) {

src/main/java/eu/siacs/conversations/parser/PresenceParser.java πŸ”—

@@ -22,6 +22,7 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.Entity;
@@ -35,12 +36,13 @@ import org.openintents.openpgp.util.OpenPgpUtils;
 public class PresenceParser extends AbstractParser
         implements Consumer<im.conversations.android.xmpp.model.stanza.Presence> {
 
-    public PresenceParser(final XmppConnectionService service, final Account account) {
-        super(service, account);
+    public PresenceParser(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
     }
 
     public void parseConferencePresence(
-            final im.conversations.android.xmpp.model.stanza.Presence packet, Account account) {
+            final im.conversations.android.xmpp.model.stanza.Presence packet) {
+        final var account = getAccount();
         final Conversation conversation =
                 packet.getFrom() == null
                         ? null
@@ -331,8 +333,8 @@ public class PresenceParser extends AbstractParser
     }
 
     private void parseContactPresence(
-            final im.conversations.android.xmpp.model.stanza.Presence packet,
-            final Account account) {
+            final im.conversations.android.xmpp.model.stanza.Presence packet) {
+        final var account = getAccount();
         final PresenceGenerator mPresenceGenerator = mXmppConnectionService.getPresenceGenerator();
         final Jid from = packet.getFrom();
         if (from == null || from.equals(account.getJid())) {
@@ -378,8 +380,7 @@ public class PresenceParser extends AbstractParser
             final var connection = account.getXmppConnection();
             if (nodeHash != null && connection != null) {
                 final var discoFuture =
-                        connection
-                                .getManager(DiscoManager.class)
+                        this.getManager(DiscoManager.class)
                                 .infoOrCache(Entity.presence(from), nodeHash.node, nodeHash.hash);
 
                 logDiscoFailure(from, discoFuture);
@@ -424,6 +425,7 @@ public class PresenceParser extends AbstractParser
             if (contact.setLastseen(AbstractParser.parseTimestamp(packet, 0L, true))) {
                 contact.flagInactive();
             }
+            getManager(DiscoManager.class).clear(from);
             if (from.isBareJid()) {
                 contact.clearPresences();
             } else {
@@ -492,14 +494,14 @@ public class PresenceParser extends AbstractParser
     @Override
     public void accept(final im.conversations.android.xmpp.model.stanza.Presence packet) {
         if (packet.hasChild("x", Namespace.MUC_USER)) {
-            this.parseConferencePresence(packet, account);
+            this.parseConferencePresence(packet);
         } else if (packet.hasChild("x", "http://jabber.org/protocol/muc")) {
-            this.parseConferencePresence(packet, account);
+            this.parseConferencePresence(packet);
         } else if ("error".equals(packet.getAttribute("type"))
-                && mXmppConnectionService.isMuc(account, packet.getFrom())) {
-            this.parseConferencePresence(packet, account);
+                && mXmppConnectionService.isMuc(getAccount(), packet.getFrom())) {
+            this.parseConferencePresence(packet);
         } else {
-            this.parseContactPresence(packet, account);
+            this.parseContactPresence(packet);
         }
     }
 }

src/main/java/eu/siacs/conversations/services/ChannelDiscoveryService.java πŸ”—

@@ -5,24 +5,34 @@ import androidx.annotation.NonNull;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Ordering;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Room;
 import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.http.services.MuclumbusService;
-import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.disco.items.Item;
+import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
 import okhttp3.OkHttpClient;
 import okhttp3.ResponseBody;
 import retrofit2.Call;
@@ -71,7 +81,7 @@ public class ChannelDiscoveryService {
     void discover(
             @NonNull final String query,
             Method method,
-            Map<Jid, Account> mucServices,
+            Map<Jid, XmppConnection> mucServices,
             OnChannelSearchResultsFound onChannelSearchResultsFound) {
         final List<Room> result = cache.getIfPresent(key(method, mucServices, query));
         if (result != null) {
@@ -164,8 +174,8 @@ public class ChannelDiscoveryService {
     }
 
     private void discoverChannelsLocalServers(
-            final String query, Map<Jid, Account> mucServices, final OnChannelSearchResultsFound listener) {
-        final Map<Jid, Account> localMucService = mucServices == null ? getLocalMucServices() : mucServices;
+            final String query, Map<Jid, XmppConnection> mucServices, final OnChannelSearchResultsFound listener) {
+        final var localMucService = mucServices == null ? getLocalMucServices() : mucServices;
         Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
         if (localMucService.isEmpty()) {
             listener.onChannelSearchResultsFound(Collections.emptyList());
@@ -179,58 +189,105 @@ public class ChannelDiscoveryService {
                 listener.onChannelSearchResultsFound(results);
             }
         }
-        final AtomicInteger queriesInFlight = new AtomicInteger();
-        final List<Room> rooms = new ArrayList<>();
-        for (final Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
-            Iq itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
-            queriesInFlight.incrementAndGet();
-            final var account = entry.getValue();
-            service.sendIqPacket(
-                    account,
-                    itemsRequest,
-                    (itemsResponse) -> {
-                        if (itemsResponse.getType() == Iq.Type.RESULT) {
-                            final List<Jid> items = IqParser.items(itemsResponse);
-                            for (final Jid item : items) {
-                                if (item.isDomainJid()) continue; // Only looking for MUCs for now, and by spec they have a localpart
-                                final Iq infoRequest =
-                                        service.getIqGenerator().queryDiscoInfo(item);
-                                queriesInFlight.incrementAndGet();
-                                service.sendIqPacket(
-                                        account,
-                                        infoRequest,
-                                        infoResponse -> {
-                                            if (infoResponse.getType() == Iq.Type.RESULT) {
-                                                final Room room = IqParser.parseRoom(infoResponse);
-                                                if (room != null) {
-                                                    rooms.add(room);
-                                                }
-                                                if (queriesInFlight.decrementAndGet() <= 0) {
-                                                    finishDiscoSearch(rooms, query, mucServices, listener);
-                                                }
-                                            } else {
-                                                queriesInFlight.decrementAndGet();
-                                            }
-                                        }, 20L);
+        final var roomsRoomsFuture =
+                Futures.successfulAsList(
+                        Collections2.transform(
+                                localMucService.entrySet(),
+                                e -> discoverRooms(e.getValue(), e.getKey())));
+        final var roomsFuture =
+                Futures.transform(
+                        roomsRoomsFuture,
+                        rooms -> {
+                            final var builder = new ImmutableList.Builder<Room>();
+                            for (final var inner : rooms) {
+                                if (inner == null) {
+                                    continue;
+                                }
+                                builder.addAll(inner);
                             }
-                        }
-                        if (queriesInFlight.decrementAndGet() <= 0) {
-                            finishDiscoSearch(rooms, query, mucServices, listener);
-                        }
-                    });
-        }
+                            return builder.build();
+                        },
+                        MoreExecutors.directExecutor());
+        Futures.addCallback(
+                roomsFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(ImmutableList<Room> rooms) {
+                        finishDiscoSearch(rooms, query, mucServices, listener);
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        Log.d(Config.LOGTAG, "could not perform room search", throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Collection<Room>> discoverRooms(
+            final XmppConnection connection, final Jid server) {
+        final var request = new Iq(Iq.Type.GET);
+        request.addExtension(new ItemsQuery());
+        request.setTo(server);
+        final ListenableFuture<Collection<Item>> itemsFuture =
+                Futures.transform(
+                        connection.sendIqPacket(request),
+                        iq -> {
+                            final var itemsQuery = iq.getExtension(ItemsQuery.class);
+                            if (itemsQuery == null) {
+                                return Collections.emptyList();
+                            }
+                            final var items = itemsQuery.getExtensions(Item.class);
+                            return Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
+                        },
+                        MoreExecutors.directExecutor());
+        final var roomsFutures =
+                Futures.transformAsync(
+                        itemsFuture,
+                        items -> {
+                            final var infoFutures =
+                                    Collections2.transform(
+                                            items, i -> discoverRoom(connection, i.getJid()));
+                            return Futures.successfulAsList(infoFutures);
+                        },
+                        MoreExecutors.directExecutor());
+        return Futures.transform(
+                roomsFutures,
+                rooms -> Collections2.filter(rooms, Objects::nonNull),
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Room> discoverRoom(final XmppConnection connection, final Jid room) {
+        final var request = new Iq(Iq.Type.GET);
+        request.addExtension(new InfoQuery());
+        request.setTo(room);
+        final var infoQueryResponseFuture = connection.sendIqPacket(request);
+        return Futures.transform(
+                infoQueryResponseFuture,
+                result -> {
+                    final var infoQuery = result.getExtension(InfoQuery.class);
+                    if (infoQuery == null) {
+                        return null;
+                    }
+                    return Room.of(room, infoQuery);
+                },
+                MoreExecutors.directExecutor());
     }
 
     private void finishDiscoSearch(
-            List<Room> rooms, String query, Map<Jid, Account> mucServices, OnChannelSearchResultsFound listener) {
-        Collections.sort(rooms);
-        cache.put(key(Method.LOCAL_SERVER, mucServices, ""), rooms);
+            final List<Room> rooms,
+            final String query,
+            Map<Jid, XmppConnection> mucServices,
+            final OnChannelSearchResultsFound listener) {
+        Log.d(Config.LOGTAG, "finishDiscoSearch with " + rooms.size() + " rooms");
+        final var sorted = Ordering.natural().sortedCopy(rooms);
+        cache.put(key(Method.LOCAL_SERVER, mucServices, ""), sorted);
         if (query.isEmpty()) {
-            listener.onChannelSearchResultsFound(rooms);
+            listener.onChannelSearchResultsFound(sorted);
         } else {
-            List<Room> results = copyMatching(rooms, query);
+            List<Room> results = copyMatching(sorted, query);
             cache.put(key(Method.LOCAL_SERVER, mucServices, query), results);
-            listener.onChannelSearchResultsFound(rooms);
+            listener.onChannelSearchResultsFound(sorted);
         }
     }
 
@@ -244,26 +301,24 @@ public class ChannelDiscoveryService {
         return result;
     }
 
-    private Map<Jid, Account> getLocalMucServices() {
-        final HashMap<Jid, Account> localMucServices = new HashMap<>();
-        for (Account account : service.getAccounts()) {
-            if (account.isEnabled()) {
-                final XmppConnection xmppConnection = account.getXmppConnection();
-                if (xmppConnection == null) {
-                    continue;
-                }
-                for (final String mucService : xmppConnection.getMucServers()) {
-                    final Jid jid = Jid.of(mucService);
-                    if (!localMucServices.containsKey(jid)) {
-                        localMucServices.put(jid, account);
+    private Map<Jid, XmppConnection> getLocalMucServices() {
+        final ImmutableMap.Builder<Jid, XmppConnection> localMucServices =
+                new ImmutableMap.Builder<>();
+        for (final var account : service.getAccounts()) {
+            final var connection = account.getXmppConnection();
+            if (connection != null && account.isEnabled()) {
+                for (final String mucService : connection.getMucServers()) {
+                    final Jid jid = Jid.ofOrInvalid(mucService);
+                    if (Jid.Invalid.isValid(jid)) {
+                        localMucServices.put(jid, connection);
                     }
                 }
             }
         }
-        return localMucServices;
+        return localMucServices.buildKeepingLast();
     }
 
-    private static String key(Method method, Map<Jid, Account> mucServices, String query) {
+    private static String key(Method method, Map<Jid, XmppConnection> mucServices, String query) {
         final String servicesKey = mucServices == null ? "\00" : String.join("\00", mucServices.keySet());
         return String.format("%s\00%s\00%s", method, servicesKey, query);
     }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java πŸ”—

@@ -1454,7 +1454,7 @@ public class XmppConnectionService extends Service {
     public void discoverChannels(
             String query,
             ChannelDiscoveryService.Method method,
-            Map<Jid, Account> mucServices,
+            Map<Jid, XmppConnection> mucServices,
             ChannelDiscoveryService.OnChannelSearchResultsFound onChannelSearchResultsFound) {
         mChannelDiscoveryService.discover(
                 Strings.nullToEmpty(query).trim(), method, mucServices, onChannelSearchResultsFound);
@@ -4176,6 +4176,7 @@ public class XmppConnectionService extends Service {
                 return;
             }
         }
+        // TODO use PingManager
         final Jid self = conversation.getMucOptions().getSelf().getFullJid();
         final Iq ping = new Iq(Iq.Type.GET);
         ping.setTo(self);
@@ -4795,6 +4796,10 @@ public class XmppConnectionService extends Service {
                     conversation.getAccount().getJid().asBareJid()
                             + ": leaving muc "
                             + conversation.getJid());
+            final var connection = account.getXmppConnection();
+            if (connection != null) {
+                connection.getManager(DiscoManager.class).clear(conversation.getJid().asBareJid());
+            }
         } else {
             synchronized (account.pendingConferenceLeaves) {
                 account.pendingConferenceLeaves.add(conversation);
@@ -4957,14 +4962,34 @@ public class XmppConnectionService extends Service {
             return;
         }
 
-        final var request = mIqGenerator.queryDiscoInfo(jid.asBareJid());
-        sendIqPacket(account, request, (reply) -> {
-            final var result = reply.getExtension(InfoQuery.class);
-            cb.accept(
-                result.hasFeature("http://jabber.org/protocol/muc") &&
-                result.hasIdentityWithCategory("conference")
-            );
-        });
+        final var connection = account.getXmppConnection();
+        if (connection == null) {
+            cb.accept(false); // hmmm...
+            return;
+        }
+        final ListenableFuture<InfoQuery> future =
+                connection
+                        .getManager(DiscoManager.class)
+                        .info(Entity.discoItem(jid), null);
+
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(InfoQuery result) {
+                        cb.accept(
+                            result.hasFeature("http://jabber.org/protocol/muc") &&
+                            result.hasIdentityWithCategory("conference")
+                        );
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        cb.accept(false);
+                    }
+                },
+                MoreExecutors.directExecutor()
+        );
     }
 
     public void fetchConferenceConfiguration(final Conversation conversation) {
@@ -4973,7 +4998,6 @@ public class XmppConnectionService extends Service {
 
     public void fetchConferenceConfiguration(
             final Conversation conversation, final OnConferenceConfigurationFetched callback) {
-        final Iq request = mIqGenerator.queryDiscoInfo(conversation.getJid().asBareJid());
         final var account = conversation.getAccount();
         final var connection = account.getXmppConnection();
         if (connection == null) {
@@ -4985,7 +5009,7 @@ public class XmppConnectionService extends Service {
                         .info(Entity.discoItem(conversation.getJid().asBareJid()), null);
         Futures.addCallback(
                 future,
-                new FutureCallback<InfoQuery>() {
+                new FutureCallback<>() {
                     @Override
                     public void onSuccess(InfoQuery result) {
                         final MucOptions mucOptions = conversation.getMucOptions();
@@ -6181,11 +6205,11 @@ public class XmppConnectionService extends Service {
     }
 
     public boolean confirmMessages() {
-        return getBooleanPreference("confirm_messages", R.bool.confirm_messages);
+        return appSettings.isConfirmMessages();
     }
 
     public boolean allowMessageCorrection() {
-        return getBooleanPreference("allow_message_correction", R.bool.allow_message_correction);
+        return appSettings.isAllowMessageCorrection();
     }
 
     public boolean sendChatStates() {
@@ -6193,11 +6217,11 @@ public class XmppConnectionService extends Service {
     }
 
     public boolean useTorToConnect() {
-        return getBooleanPreference("use_tor", R.bool.use_tor);
+        return appSettings.isUseTor();
     }
 
     public boolean broadcastLastActivity() {
-        return getBooleanPreference(AppSettings.BROADCAST_LAST_ACTIVITY, R.bool.last_activity);
+        return appSettings.isBroadcastLastActivity();
     }
 
     public int unreadCount() {
@@ -6979,11 +7003,6 @@ public class XmppConnectionService extends Service {
         });
     }
 
-    public void fetchCommands(Account account, final Jid jid, Consumer<Iq> callback) {
-        final var request = mIqGenerator.queryDiscoItems(jid, "http://jabber.org/protocol/commands");
-        sendIqPacket(account, request, callback);
-    }
-
     public void fetchMamPreferences(final Account account, final OnMamPreferencesFetched callback) {
         final MessageArchiveService.Version version = MessageArchiveService.Version.get(account);
         final Iq request = new Iq(Iq.Type.GET);

src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java πŸ”—

@@ -41,6 +41,7 @@ import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
 
 public class ChannelDiscoveryActivity extends XmppActivity
         implements MenuItem.OnActionExpandListener,
@@ -58,7 +59,7 @@ public class ChannelDiscoveryActivity extends XmppActivity
 
     private String[] pendingServices = null;
     private ChannelDiscoveryService.Method method = ChannelDiscoveryService.Method.LOCAL_SERVER;
-    private HashMap<Jid, Account> mucServices = null;
+    private HashMap<Jid, XmppConnection> mucServices = null;
 
     private boolean optedIn = false;
 
@@ -70,7 +71,7 @@ public class ChannelDiscoveryActivity extends XmppActivity
         if (pendingServices != null) {
             mucServices = new HashMap<>();
             for (int i = 0; i < pendingServices.length; i += 2) {
-                mucServices.put(Jid.of(pendingServices[i]), xmppConnectionService.findAccountByJid(Jid.of(pendingServices[i+1])));
+                mucServices.put(Jid.of(pendingServices[i]), xmppConnectionService.findAccountByJid(Jid.of(pendingServices[i+1])).getXmppConnection());
             }
         }
 

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java πŸ”—

@@ -210,6 +210,7 @@ import eu.siacs.conversations.xmpp.manager.DiscoManager;
 
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.disco.items.Item;
 import im.conversations.android.xmpp.model.stanza.Iq;
 
 import org.jetbrains.annotations.NotNull;
@@ -3573,28 +3574,40 @@ public class ConversationFragment extends XmppFragment
         } else {
             if (!delayShow) conversation.showViewPager();
             binding.commandsViewProgressbar.setVisibility(View.VISIBLE);
-            activity.xmppConnectionService.fetchCommands(conversation.getAccount(), commandJid, (iq) -> {
-                if (activity == null) return;
+            final var discoManager = conversation.getAccount().getXmppConnection().getManager(DiscoManager.class);
+            final var future = discoManager.items(Entity.discoItem(commandJid), Namespace.COMMANDS);
+            Futures.addCallback(
+                    future,
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(Collection<Item> result) {
+                            if (activity == null) return;
+
+                            activity.runOnUiThread(() -> {
+                                binding.commandsViewProgressbar.setVisibility(View.GONE);
+                                commandAdapter.clear();
+                                for (final var command : result) {
+                                    commandAdapter.add(new CommandAdapter.Command0050(command));
+                                }
 
-                activity.runOnUiThread(() -> {
-                    binding.commandsViewProgressbar.setVisibility(View.GONE);
-                    commandAdapter.clear();
-                    if (iq.getType() == Iq.Type.RESULT) {
-                        for (Element child : iq.query().getChildren()) {
-                            if (!"item".equals(child.getName()) || !Namespace.DISCO_ITEMS.equals(child.getNamespace())) continue;
-                            commandAdapter.add(new CommandAdapter.Command0050(child));
-                        }
-                    }
+                                if (mucConfig != null) commandAdapter.add(mucConfig);
+
+                                if (commandAdapter.getCount() < 1) {
+                                    conversation.hideViewPager();
+                                } else if (delayShow) {
+                                    conversation.showViewPager();
+                                }
+                            });
 
-                    if (mucConfig != null) commandAdapter.add(mucConfig);
+                        }
 
-                    if (commandAdapter.getCount() < 1) {
-                        conversation.hideViewPager();
-                    } else if (delayShow) {
-                        conversation.showViewPager();
-                    }
-                });
-            });
+                        @Override
+                        public void onFailure(@NonNull Throwable throwable) {
+                            Log.d(Config.LOGTAG, "Failed to get commands: " + throwable);
+                        }
+                    },
+                    MoreExecutors.directExecutor()
+            );
         }
     }
 

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java πŸ”—

@@ -96,6 +96,7 @@ import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.XmppConnection.Features;
 import eu.siacs.conversations.xmpp.forms.Data;
+import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 
 import static eu.siacs.conversations.utils.PermissionUtils.allGranted;
@@ -1362,7 +1363,8 @@ public class EditAccountActivity extends OmemoActivity
             this.binding.accountRegisterNew.setVisibility(View.GONE);
         }
         if (this.mAccount.isOnlineAndConnected() && !this.mFetchingAvatar) {
-            final Features features = this.mAccount.getXmppConnection().getFeatures();
+            final var connection = this.mAccount.getXmppConnection();
+            final Features features = connection.getFeatures();
             this.binding.stats.setVisibility(View.VISIBLE);
             boolean showBatteryWarning = isOptimizingBattery();
             boolean showDataSaverWarning = isAffectedByDataSaver();
@@ -1375,7 +1377,7 @@ public class EditAccountActivity extends OmemoActivity
             } else {
                 this.binding.serverInfoRosterVersion.setText(R.string.server_info_unavailable);
             }
-            if (features.carbons()) {
+            if (connection.getManager(CarbonsManager.class).isEnabled()) {
                 this.binding.serverInfoCarbons.setText(R.string.server_info_available);
             } else {
                 this.binding.serverInfoCarbons.setText(R.string.server_info_unavailable);

src/main/java/eu/siacs/conversations/xmpp/Managers.java πŸ”—

@@ -0,0 +1,28 @@
+package eu.siacs.conversations.xmpp;
+
+import android.content.Context;
+import com.google.common.collect.ClassToInstanceMap;
+import com.google.common.collect.ImmutableClassToInstanceMap;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.manager.AbstractManager;
+import eu.siacs.conversations.xmpp.manager.CarbonsManager;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.PingManager;
+import eu.siacs.conversations.xmpp.manager.PresenceManager;
+
+public class Managers {
+
+    private Managers() {
+        throw new AssertionError("Do not instantiate me");
+    }
+
+    public static ClassToInstanceMap<AbstractManager> get(
+            final XmppConnectionService context, final XmppConnection connection) {
+        return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
+                .put(CarbonsManager.class, new CarbonsManager(context, connection))
+                .put(DiscoManager.class, new DiscoManager(context, connection))
+                .put(PingManager.class, new PingManager(context, connection))
+                .put(PresenceManager.class, new PresenceManager(context, connection))
+                .build();
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java πŸ”—

@@ -20,7 +20,6 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ClassToInstanceMap;
-import com.google.common.collect.ImmutableClassToInstanceMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
@@ -121,11 +120,14 @@ import eu.siacs.conversations.xmpp.bind.Bind2;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.jingle.OnJinglePacketReceived;
 import eu.siacs.conversations.xmpp.manager.AbstractManager;
+import eu.siacs.conversations.xmpp.manager.CarbonsManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.PingManager;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.AuthenticationFailure;
 import im.conversations.android.xmpp.model.AuthenticationRequest;
 import im.conversations.android.xmpp.model.AuthenticationStreamFeature;
+import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.StreamElement;
 import im.conversations.android.xmpp.model.bind2.Bind;
 import im.conversations.android.xmpp.model.bind2.Bound;
@@ -264,16 +266,14 @@ public class XmppConnection implements Runnable {
         this.account = account;
         this.mXmppConnectionService = service;
         this.appSettings = mXmppConnectionService.getAppSettings();
-        this.presenceListener = new PresenceParser(service, account);
-        this.unregisteredIqListener = new IqParser(service, account);
-        this.messageListener = new MessageParser(service, account);
-        this.bindListener = new BindProcessor(service, account);
-        this.managers =
-                new ImmutableClassToInstanceMap.Builder<AbstractManager>()
-                        .put(
-                                DiscoManager.class,
-                                new DiscoManager(service, this))
-                        .build();
+        this.presenceListener = new PresenceParser(service, this);
+        // TODO rename this to Iq request handler (it handles only IQ get and set; throw assert
+        // error in handler just to be safe)
+        // TODO requires roster and blocking not to be handled by this
+        this.unregisteredIqListener = new IqParser(service, this);
+        this.messageListener = new MessageParser(service, this);
+        this.bindListener = new BindProcessor(service, this);
+        this.managers = Managers.get(service, this);
     }
 
     private static void fixResource(final Context context, final Account account) {
@@ -956,7 +956,6 @@ public class XmppConnection implements Runnable {
                             Config.LOGTAG,
                             account.getJid().asBareJid()
                                     + ": successfully enabled carbons (via Bind 2.0)");
-                    features.carbonsEnabled = true;
                 } else if (currentLoginInfo.inlineBindFeatures != null
                         && currentLoginInfo.inlineBindFeatures.contains(Namespace.CARBONS)) {
                     negotiatedCarbons = true;
@@ -964,7 +963,6 @@ public class XmppConnection implements Runnable {
                             Config.LOGTAG,
                             account.getJid().asBareJid()
                                     + ": successfully enabled carbons (via Bind 2.0/implicit)");
-                    features.carbonsEnabled = true;
                 } else {
                     negotiatedCarbons = false;
                 }
@@ -2252,7 +2250,7 @@ public class XmppConnection implements Runnable {
 
     private void sendPostBindInitialization(
             final boolean waitForDisco, final boolean carbonsEnabled) {
-        features.carbonsEnabled = carbonsEnabled;
+        getManager(CarbonsManager.class).setEnabledOnBind(carbonsEnabled);
         features.blockListRequested = false;
         getManager(DiscoManager.class).clear();
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": starting service discovery");
@@ -2442,35 +2440,15 @@ public class XmppConnection implements Runnable {
                 advancedStreamFeaturesLoadedListeners) {
             listener.onAdvancedStreamFeaturesAvailable(account);
         }
-        if (getFeatures().carbons() && !features.carbonsEnabled) {
-            sendEnableCarbons();
+        final var carbonsManager = getManager(CarbonsManager.class);
+        if (carbonsManager.hasFeature() && !carbonsManager.isEnabled()) {
+            carbonsManager.enable();
         }
         if (getFeatures().commands()) {
             discoverCommands();
         }
     }
 
-    private void sendEnableCarbons() {
-        final Iq iq = new Iq(Iq.Type.SET);
-        iq.addChild("enable", Namespace.CARBONS);
-        this.sendIqPacket(
-                iq,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.RESULT) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid() + ": successfully enabled carbons");
-                        features.carbonsEnabled = true;
-                    } else {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": could not enable carbons "
-                                        + packet);
-                    }
-                });
-    }
-
     private void processStreamError(final StreamError streamError) throws IOException {
         final var loginInfo = this.loginInfo;
         final var isSecureLoggedIn = isSecure() && LoginInfo.isSuccess(loginInfo);
@@ -2602,6 +2580,10 @@ public class XmppConnection implements Runnable {
         return String.format("%s.%s", BuildConfig.APP_NAME, CryptoHelper.random(3));
     }
 
+    public void sendRequestStanza() {
+        this.sendPacket(new Request());
+    }
+
     public ListenableFuture<Iq> sendIqPacket(final Iq request) {
         final SettableFuture<Iq> settable = SettableFuture.create();
         this.sendIqPacket(
@@ -2655,6 +2637,36 @@ public class XmppConnection implements Runnable {
         return packet.getId();
     }
 
+    public void sendResultFor(final Iq request, final Extension... extensions) {
+        final var from = request.getFrom();
+        final var id = request.getId();
+        final var response = new Iq(Iq.Type.RESULT);
+        response.setTo(from);
+        response.setId(id);
+        for (final Extension extension : extensions) {
+            response.addExtension(extension);
+        }
+        this.sendPacket(response);
+    }
+
+    public void sendErrorFor(
+            final Iq request,
+            final im.conversations.android.xmpp.model.error.Error.Type type,
+            final Condition condition,
+            final im.conversations.android.xmpp.model.error.Error.Extension... extensions) {
+        final var from = request.getFrom();
+        final var id = request.getId();
+        final var response = new Iq(Iq.Type.ERROR);
+        response.setTo(from);
+        response.setId(id);
+        final var error =
+                response.addExtension(new im.conversations.android.xmpp.model.error.Error());
+        error.setType(type);
+        error.setCondition(condition);
+        error.addExtensions(extensions);
+        this.sendPacket(response);
+    }
+
     public void sendMessagePacket(final im.conversations.android.xmpp.model.stanza.Message packet) {
         this.sendPacket(packet);
     }
@@ -2719,12 +2731,7 @@ public class XmppConnection implements Runnable {
     }
 
     public void sendPing() {
-        if (!r()) {
-            final Iq iq = new Iq(Iq.Type.GET);
-            iq.setFrom(account.getJid());
-            iq.addChild("ping", Namespace.PING);
-            this.sendIqPacket(iq, null);
-        }
+        this.getManager(PingManager.class).ping();
         this.lastPingSent = SystemClock.elapsedRealtime();
     }
 
@@ -2938,17 +2945,16 @@ public class XmppConnection implements Runnable {
 
     public void trackOfflineMessageRetrieval(boolean trackOfflineMessageRetrieval) {
         if (trackOfflineMessageRetrieval) {
-            final Iq iqPing = new Iq(Iq.Type.GET);
-            iqPing.addChild("ping", Namespace.PING);
-            this.sendIqPacket(
-                    iqPing,
-                    (response) -> {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": got ping response after sending initial presence");
-                        XmppConnection.this.offlineMessagesRetrieved = true;
-                    });
+            getManager(PingManager.class)
+                    .ping(
+                            () -> {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": got ping response after sending initial"
+                                                + " presence");
+                                this.offlineMessagesRetrieved = true;
+                            });
         } else {
             this.offlineMessagesRetrieved = true;
         }
@@ -2992,6 +2998,10 @@ public class XmppConnection implements Runnable {
         return this.account;
     }
 
+    public Features getStreamFeatures() {
+        return this.features;
+    }
+
     private class MyKeyManager implements X509KeyManager {
         @Override
         public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {
@@ -3139,8 +3149,9 @@ public class XmppConnection implements Runnable {
     }
 
     public class Features {
-        XmppConnection connection;
-        private boolean carbonsEnabled = false;
+        private final XmppConnection connection;
+
+        // TODO move these three into their respective managers or into XmppConnection
         private boolean encryptionEnabled = false;
         private boolean blockListRequested = false;
 
@@ -3153,10 +3164,6 @@ public class XmppConnection implements Runnable {
             return infoQuery != null && infoQuery.getFeatureStrings().contains(feature);
         }
 
-        public boolean carbons() {
-            return hasDiscoFeature(account.getDomain(), Namespace.CARBONS);
-        }
-
         public boolean commands() {
             return hasDiscoFeature(account.getDomain(), Namespace.COMMANDS);
         }

src/main/java/eu/siacs/conversations/xmpp/manager/CarbonsManager.java πŸ”—

@@ -0,0 +1,62 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.carbons.Enable;
+import im.conversations.android.xmpp.model.stanza.Iq;
+
+public class CarbonsManager extends AbstractManager {
+
+    private boolean enabled = false;
+
+    public CarbonsManager(final XmppConnectionService context, final XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void setEnabledOnBind(final boolean enabledOnBind) {
+        this.enabled = enabledOnBind;
+    }
+
+    public void enable() {
+        final var request = new Iq(Iq.Type.SET);
+        request.addExtension(new Enable());
+        final var future = this.connection.sendIqPacket(request);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Iq result) {
+                        CarbonsManager.this.enabled = true;
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": successfully enabled carbons");
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid() + ": could not enable carbons",
+                                throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public boolean isEnabled() {
+        return this.enabled;
+    }
+
+    public boolean hasFeature() {
+        return getManager(DiscoManager.class).hasServerFeature(Namespace.CARBONS);
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/DiscoManager.java πŸ”—

@@ -4,27 +4,38 @@ import android.content.Context;
 import android.util.Log;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.AppSettings;
+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.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.EntityCapabilities;
 import im.conversations.android.xmpp.EntityCapabilities2;
+import im.conversations.android.xmpp.ServiceDescription;
 import im.conversations.android.xmpp.model.Hash;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.disco.items.Item;
 import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
 import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.version.Version;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -34,7 +45,44 @@ import org.jspecify.annotations.Nullable;
 
 public class DiscoManager extends AbstractManager {
 
-    public static final String CAPABILITY_NODE = "http://conversations.im";
+    public static final String CAPABILITY_NODE = "https://cheogram.com";
+
+    private final List<String> STATIC_FEATURES =
+            Arrays.asList(
+                    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,
+                    Namespace.ENTITY_CAPABILITIES,
+                    Namespace.ENTITY_CAPABILITIES_2,
+                    Namespace.DISCO_INFO,
+                    "urn:xmpp:avatar:metadata+notify",
+                    Namespace.NICK + "+notify",
+                    Namespace.PING,
+                    Namespace.VERSION,
+                    Namespace.CHAT_STATES,
+                    Namespace.REACTIONS);
+    private final List<String> MESSAGE_CONFIRMATION_FEATURES =
+            Arrays.asList(Namespace.CHAT_MARKERS, Namespace.DELIVERY_RECEIPTS);
+    private final List<String> MESSAGE_CORRECTION_FEATURES =
+            Collections.singletonList(Namespace.LAST_MESSAGE_CORRECTION);
+    private final List<String> PRIVACY_SENSITIVE =
+            Collections.singletonList(
+                    "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
+                    );
+    private final List<String> VOIP_NAMESPACES =
+            Arrays.asList(
+                    Namespace.JINGLE_TRANSPORT_ICE_UDP,
+                    Namespace.JINGLE_FEATURE_AUDIO,
+                    Namespace.JINGLE_FEATURE_VIDEO,
+                    Namespace.JINGLE_APPS_RTP,
+                    Namespace.JINGLE_APPS_DTLS,
+                    Namespace.JINGLE_MESSAGE);
 
     // this is the runtime cache that stores disco information for all entities seen during a
     // session
@@ -93,7 +141,7 @@ public class DiscoManager extends AbstractManager {
     public ListenableFuture<Void> infoOrCache(
             final Entity entity, final String node, final EntityCapabilities.Hash hash) {
         final var cached = getDatabase().getInfoQuery(hash);
-        if (cached != null) {
+        if (cached != null && Config.ENABLE_CAPS_CACHE) {
             if (node == null || hash != null) {
                 this.put(entity.address, cached);
             }
@@ -110,7 +158,7 @@ public class DiscoManager extends AbstractManager {
 
     public ListenableFuture<InfoQuery> info(
             final Entity entity, @Nullable final String node, final EntityCapabilities.Hash hash) {
-        final var requestNode = hash != null && node != null ? hash.capabilityNode(node) : node;
+        final var requestNode = hash != null ? hash.capabilityNode(node) : node;
         final var iqRequest = new Iq(Iq.Type.GET);
         iqRequest.setTo(entity.address);
         final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
@@ -261,6 +309,114 @@ public class DiscoManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
+    ServiceDescription getServiceDescription() {
+        final var appSettings = new AppSettings(context);
+        final var account = connection.getAccount();
+        final ImmutableList.Builder<String> features = ImmutableList.builder();
+        features.add("http://jabber.org/protocol/xhtml-im");
+        features.add("urn:xmpp:bob");
+        if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
+            features.add(Namespace.MDS_DISPLAYED + "+notify");
+        }
+        if (appSettings.isConfirmMessages()) {
+            features.addAll(MESSAGE_CONFIRMATION_FEATURES);
+        }
+        if (appSettings.isAllowMessageCorrection()) {
+            features.addAll(MESSAGE_CORRECTION_FEATURES);
+        }
+        if (Config.supportOmemo()) {
+            features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
+        }
+        if (!appSettings.isUseTor() && !account.isOnion()) {
+            features.addAll(PRIVACY_SENSITIVE);
+            features.addAll(VOIP_NAMESPACES);
+            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
+        }
+        if (appSettings.isBroadcastLastActivity()) {
+            features.add(Namespace.IDLE);
+        }
+        if (connection.getFeatures().bookmarks2()) {
+            features.add(Namespace.BOOKMARKS2 + "+notify");
+        } else {
+            features.add(Namespace.BOOKMARKS + "+notify");
+        }
+        return new ServiceDescription(
+                features.build(),
+                new ServiceDescription.Identity(BuildConfig.APP_NAME, "client", getIdentityType()));
+    }
+
+    String getIdentityVersion() {
+        return BuildConfig.VERSION_NAME;
+    }
+
+    String getIdentityType() {
+        if ("chromium".equals(android.os.Build.BRAND)) {
+            return "pc";
+        } else if (context.getResources().getBoolean(R.bool.is_device_table)) {
+            return "tablet";
+        } else {
+            return "phone";
+        }
+    }
+
+    public void handleVersionRequest(final Iq request) {
+        final var version = new Version();
+        version.setSoftwareName(context.getString(R.string.app_name));
+        version.setVersion(getIdentityVersion());
+        final StringBuilder os = new StringBuilder();
+        if ("chromium".equals(android.os.Build.BRAND)) {
+            os.append("Chrome OS");
+        } else {
+            os.append("Android");
+        }
+        os.append(" ");
+        os.append(android.os.Build.VERSION.RELEASE);
+        if (QuickConversationsService.isPlayStoreFlavor()) {
+            os.append(" (");
+            os.append(android.os.Build.BOARD);
+            os.append(", ");
+            os.append(android.os.Build.FINGERPRINT);
+            os.append(")");
+        }
+        version.setOs(os.toString());
+
+        Log.d(Config.LOGTAG, "responding to version request from " + request.getFrom());
+        connection.sendResultFor(request, version);
+    }
+
+    public void handleInfoQuery(final Iq request) {
+        final var infoQueryRequest = request.getExtension(InfoQuery.class);
+        final var nodeRequest = infoQueryRequest.getNode();
+        final ServiceDescription serviceDescription;
+        if (Strings.isNullOrEmpty(nodeRequest)) {
+            serviceDescription = getServiceDescription();
+            Log.d(Config.LOGTAG, "responding to disco request w/o node from " + request.getFrom());
+        } else {
+            final var hash = buildHashFromNode(nodeRequest);
+            final var cachedServiceDescription =
+                    hash != null
+                            ? getManager(PresenceManager.class).getCachedServiceDescription(hash)
+                            : null;
+            if (cachedServiceDescription != null) {
+                Log.d(
+                        Config.LOGTAG,
+                        "responding to disco request from "
+                                + request.getFrom()
+                                + " to node "
+                                + nodeRequest
+                                + " using hash "
+                                + hash.getClass().getName());
+                serviceDescription = cachedServiceDescription;
+            } else {
+                connection.sendErrorFor(request, Error.Type.CANCEL, new Condition.ItemNotFound());
+                return;
+            }
+        }
+        final var infoQuery = serviceDescription.asInfoQuery();
+        infoQuery.setNode(nodeRequest);
+        connection.sendResultFor(request, infoQuery);
+    }
+
     public Map<Jid, InfoQuery> getServerItems() {
         final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
         final var domain = connection.getAccount().getDomain();
@@ -282,6 +438,11 @@ public class DiscoManager extends AbstractManager {
         return builder.buildKeepingLast();
     }
 
+    public boolean hasServerFeature(final String feature) {
+        final var infoQuery = this.get(getAccount().getDomain());
+        return infoQuery != null && infoQuery.hasFeature(feature);
+    }
+
     private void put(final Jid address, final InfoQuery infoQuery) {
         synchronized (this.entityInformation) {
             this.entityInformation.put(address, infoQuery);
@@ -306,6 +467,22 @@ public class DiscoManager extends AbstractManager {
         }
     }
 
+    public void clear(final Jid address) {
+        synchronized (this.entityInformation) {
+            if (address.isFullJid()) {
+                this.entityInformation.remove(address);
+            } else {
+                final var iterator = this.entityInformation.entrySet().iterator();
+                while (iterator.hasNext()) {
+                    final var entry = iterator.next();
+                    if (entry.getKey().asBareJid().equals(address)) {
+                        iterator.remove();
+                    }
+                }
+            }
+        }
+    }
+
     public static final class CapsHashMismatchException extends IllegalStateException {
         public CapsHashMismatchException(final String message) {
             super(message);

src/main/java/eu/siacs/conversations/xmpp/manager/PingManager.java πŸ”—

@@ -0,0 +1,48 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import eu.siacs.conversations.services.XmppConnectionService;
+import im.conversations.android.xmpp.model.ping.Ping;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import java.util.concurrent.TimeoutException;
+
+public class PingManager extends AbstractManager {
+
+    public PingManager(final XmppConnectionService context, final XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public void ping() {
+        if (connection.getStreamFeatures().sm()) {
+            this.connection.sendRequestStanza();
+        } else {
+            this.connection.sendIqPacket(new Iq(Iq.Type.GET, new Ping()));
+        }
+    }
+
+    public void ping(final Runnable runnable) {
+        final var pingFuture = this.connection.sendIqPacket(new Iq(Iq.Type.GET, new Ping()));
+        Futures.addCallback(
+                pingFuture,
+                new FutureCallback<Iq>() {
+                    @Override
+                    public void onSuccess(Iq result) {
+                        runnable.run();
+                    }
+
+                    @Override
+                    public void onFailure(final @NonNull Throwable t) {
+                        if (t instanceof TimeoutException) {
+                            return;
+                        }
+                        runnable.run();
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/manager/PresenceManager.java πŸ”—

@@ -0,0 +1,59 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.EntityCapabilities;
+import im.conversations.android.xmpp.EntityCapabilities2;
+import im.conversations.android.xmpp.ServiceDescription;
+import im.conversations.android.xmpp.model.capabilties.Capabilities;
+import im.conversations.android.xmpp.model.capabilties.LegacyCapabilities;
+import im.conversations.android.xmpp.model.pgp.Signed;
+import im.conversations.android.xmpp.model.stanza.Presence;
+import java.util.HashMap;
+import java.util.Map;
+
+public class PresenceManager extends AbstractManager {
+
+    private final Map<EntityCapabilities.Hash, ServiceDescription> serviceDescriptions =
+            new HashMap<>();
+
+    public PresenceManager(XmppConnectionService context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public Presence getPresence(final Presence.Availability availability, final boolean personal) {
+        final var account = connection.getAccount();
+        final var serviceDiscoveryFeatures = getManager(DiscoManager.class).getServiceDescription();
+        final var infoQuery = serviceDiscoveryFeatures.asInfoQuery();
+        final var capsHash = EntityCapabilities.hash(infoQuery);
+        final var caps2Hash = EntityCapabilities2.hash(infoQuery);
+        serviceDescriptions.put(capsHash, serviceDiscoveryFeatures);
+        serviceDescriptions.put(caps2Hash, serviceDiscoveryFeatures);
+        final var capabilities = new Capabilities();
+        capabilities.setHash(caps2Hash);
+        final var legacyCapabilities = new LegacyCapabilities();
+        legacyCapabilities.setNode(DiscoManager.CAPABILITY_NODE);
+        legacyCapabilities.setHash(capsHash);
+        final var presence = new Presence();
+        presence.addExtension(capabilities);
+        presence.addExtension(legacyCapabilities);
+
+        if (personal) {
+            final String pgpSignature = account.getPgpSignature();
+            final String message = account.getPresenceStatusMessage();
+            presence.setAvailability(availability);
+            presence.setStatus(message);
+            if (pgpSignature != null) {
+                final var signed = new Signed();
+                signed.setContent(pgpSignature);
+                presence.addExtension(new Signed());
+            }
+        }
+        return presence;
+    }
+
+    public ServiceDescription getCachedServiceDescription(final EntityCapabilities.Hash hash) {
+        return this.serviceDescriptions.get(hash);
+    }
+}

src/main/java/im/conversations/android/xmpp/ServiceDescription.java πŸ”—

@@ -0,0 +1,49 @@
+package im.conversations.android.xmpp;
+
+import com.google.common.collect.Collections2;
+import im.conversations.android.xmpp.model.disco.info.Feature;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import java.util.Collection;
+import java.util.List;
+
+public class ServiceDescription {
+    public final List<String> features;
+    public final Identity identity;
+
+    public ServiceDescription(List<String> features, Identity identity) {
+        this.features = features;
+        this.identity = identity;
+    }
+
+    public InfoQuery asInfoQuery() {
+        final var infoQuery = new InfoQuery();
+        final Collection<Feature> features =
+                Collections2.transform(
+                        this.features,
+                        sf -> {
+                            final var feature = new Feature();
+                            feature.setVar(sf);
+                            return feature;
+                        });
+        infoQuery.addExtensions(features);
+        final var identity =
+                infoQuery.addExtension(
+                        new im.conversations.android.xmpp.model.disco.info.Identity());
+        identity.setIdentityName(this.identity.name);
+        identity.setCategory(this.identity.category);
+        identity.setType(this.identity.type);
+        return infoQuery;
+    }
+
+    public static class Identity {
+        public final String name;
+        public final String category;
+        public final String type;
+
+        public Identity(String name, String category, String type) {
+            this.name = name;
+            this.category = category;
+            this.type = type;
+        }
+    }
+}

src/main/java/im/conversations/android/xmpp/model/data/Data.java πŸ”—

@@ -33,6 +33,11 @@ public class Data extends Extension {
         return Iterables.find(getFields(), f -> name.equals(f.getFieldName()), null);
     }
 
+    public String getValue(final String name) {
+        final var field = getFieldByName(name);
+        return field == null ? null : field.getValue();
+    }
+
     private void addField(final String name, final Object value) {
         addField(name, value, null);
     }

src/main/java/im/conversations/android/xmpp/model/stanza/Iq.java πŸ”—

@@ -1,12 +1,11 @@
 package im.conversations.android.xmpp.model.stanza;
 
 import com.google.common.base.Strings;
-
 import eu.siacs.conversations.xml.Element;
-
 import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.error.Error;
-
+import java.util.Arrays;
 import java.util.Locale;
 
 @XmlElement
@@ -23,6 +22,11 @@ public class Iq extends Stanza {
         this.setAttribute("type", type.toString().toLowerCase(Locale.ROOT));
     }
 
+    public Iq(final Type type, final Extension... extensions) {
+        this(type);
+        this.addExtensions(Arrays.asList(extensions));
+    }
+
     // TODO get rid of timeout
     public enum Type {
         SET,

src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java πŸ”—

@@ -9,19 +9,18 @@ import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import im.conversations.android.xmpp.model.stanza.Iq;
 
-public class BindProcessor implements Runnable {
+public class BindProcessor extends XmppConnection.Delegate implements Runnable {
 
     private final XmppConnectionService service;
-    private final Account account;
 
-    public BindProcessor(XmppConnectionService service, Account account) {
+    public BindProcessor(final XmppConnectionService service, final XmppConnection connection) {
+        super(service, connection);
         this.service = service;
-        this.account = account;
     }
 
     @Override
     public void run() {
-        final XmppConnection connection = account.getXmppConnection();
+        final var account = connection.getAccount();
         final var features = connection.getFeatures();
         service.cancelAvatarFetches(account);
         final boolean loggedInSuccessfully =

src/main/res/values/defaults.xml πŸ”—

@@ -1,6 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="default_resource">Phone</string>
     <bool name="portrait_only">true</bool>
     <bool name="enter_is_send">false</bool>
     <bool name="notifications_from_strangers">true</bool>