Detailed changes
  
  
    
    @@ -91,6 +91,9 @@ dependencies {
     quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.2.0'
 
     testImplementation 'junit:junit:4.13.2'
+    testImplementation 'org.robolectric:robolectric:4.14.1'
+    androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+
 }
 
 ext {
  
  
  
    
    @@ -27,6 +27,7 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -101,7 +102,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private XmppConnection xmppConnection = null;
     private long mEndGracePeriod = 0L;
     private final Map<Jid, Bookmark> bookmarks = new HashMap<>();
-    private Presence.Status presenceStatus;
+    private im.conversations.android.xmpp.model.stanza.Presence.Availability presenceStatus;
     private String presenceStatusMessage;
     private String pinnedMechanism;
     private String pinnedChannelBinding;
@@ -121,7 +122,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                 null,
                 null,
                 Resolver.XMPP_PORT_STARTTLS,
-                Presence.Status.ONLINE,
+                im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE,
                 null,
                 null,
                 null,
@@ -140,7 +141,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
             String displayName,
             String hostname,
             int port,
-            final Presence.Status status,
+            final im.conversations.android.xmpp.model.stanza.Presence.Availability status,
             String statusMessage,
             final String pinnedMechanism,
             final String pinnedChannelBinding,
@@ -203,7 +204,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                 cursor.getString(cursor.getColumnIndexOrThrow(DISPLAY_NAME)),
                 cursor.getString(cursor.getColumnIndexOrThrow(HOSTNAME)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(PORT)),
-                Presence.Status.fromShowString(
+                im.conversations.android.xmpp.model.stanza.Presence.Availability.valueOfShown(
                         cursor.getString(cursor.getColumnIndexOrThrow(STATUS))),
                 cursor.getString(cursor.getColumnIndexOrThrow(STATUS_MESSAGE)),
                 cursor.getString(cursor.getColumnIndexOrThrow(PINNED_MECHANISM)),
@@ -451,11 +452,12 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                 && getXmppConnection().getAttempt() >= 3;
     }
 
-    public Presence.Status getPresenceStatus() {
+    public im.conversations.android.xmpp.model.stanza.Presence.Availability getPresenceStatus() {
         return this.presenceStatus;
     }
 
-    public void setPresenceStatus(Presence.Status status) {
+    public void setPresenceStatus(
+            im.conversations.android.xmpp.model.stanza.Presence.Availability status) {
         this.presenceStatus = status;
     }
 
@@ -584,9 +586,18 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public int activeDevicesWithRtpCapability() {
+        final var connection = getXmppConnection();
+        if (connection == null) {
+            return 0;
+        }
         int i = 0;
-        for (Presence presence : getSelfContact().getPresences().getPresences()) {
-            if (RtpCapability.check(presence) != RtpCapability.Capability.NONE) {
+        for (String resource : getSelfContact().getPresences().getPresencesMap().keySet()) {
+            final var jid =
+                    Strings.isNullOrEmpty(resource)
+                            ? getJid().asBareJid()
+                            : getJid().withResource(resource);
+            if (RtpCapability.check(connection.getManager(DiscoManager.class).get(jid))
+                    != RtpCapability.Capability.NONE) {
                 i++;
             }
         }
  
  
  
    
    @@ -17,6 +17,7 @@ import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
 import eu.siacs.conversations.xmpp.pep.Avatar;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -55,7 +56,7 @@ public class Contact implements ListItem, Blockable {
     private String photoUri;
     private final JSONObject keys;
     private JSONArray groups = new JSONArray();
-    private final Presences presences = new Presences();
+    private final Presences presences = new Presences(this);
     protected Account account;
     protected Avatar avatar;
 
@@ -275,7 +276,7 @@ public class Contact implements ListItem, Blockable {
         this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
     }
 
-    public Presence.Status getShownStatus() {
+    public im.conversations.android.xmpp.model.stanza.Presence.Availability getShownStatus() {
         return this.presences.getShownStatus();
     }
 
  
  
  
    
    @@ -14,9 +14,10 @@ import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
-import eu.siacs.conversations.xmpp.forms.Data;
-import eu.siacs.conversations.xmpp.forms.Field;
 import eu.siacs.conversations.xmpp.pep.Avatar;
+import im.conversations.android.xmpp.model.data.Data;
+import im.conversations.android.xmpp.model.data.Field;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -43,7 +44,7 @@ public class MucOptions {
     public OnRenameListener onRenameListener = null;
     private boolean mAutoPushConfiguration = true;
     private final Account account;
-    private ServiceDiscoveryResult serviceDiscoveryResult;
+    private InfoQuery infoQuery;
     private boolean isOnline = false;
     private Error error = Error.NONE;
     private User self;
@@ -110,15 +111,24 @@ public class MucOptions {
         return MessageArchiveService.Version.has(getFeatures());
     }
 
-    public boolean updateConfiguration(ServiceDiscoveryResult serviceDiscoveryResult) {
-        this.serviceDiscoveryResult = serviceDiscoveryResult;
+    private InfoQuery getServiceDiscoveryResult() {
+        return this.infoQuery;
+    }
+
+    public boolean updateConfiguration(final InfoQuery serviceDiscoveryResult) {
+        this.infoQuery = serviceDiscoveryResult;
+        final var roomInfo = getRoomInfoForm();
         String name;
-        Field roomConfigName = getRoomInfoForm().getFieldByName("muc#roomconfig_roomname");
+        Field roomConfigName =
+                roomInfo == null ? null : roomInfo.getFieldByName("muc#roomconfig_roomname");
         if (roomConfigName != null) {
             name = roomConfigName.getValue();
         } else {
             final var identities = serviceDiscoveryResult.getIdentities();
-            final String identityName = !identities.isEmpty() ? identities.get(0).getName() : null;
+            final String identityName =
+                    !identities.isEmpty()
+                            ? Iterables.getFirst(identities, null).getIdentityName()
+                            : null;
             final Jid jid = conversation.getJid();
             if (identityName != null && !identityName.equals(jid == null ? null : jid.getLocal())) {
                 name = identityName;
@@ -140,11 +150,11 @@ public class MucOptions {
     }
 
     private Data getRoomInfoForm() {
-        final List<Data> forms =
-                serviceDiscoveryResult == null
-                        ? Collections.emptyList()
-                        : serviceDiscoveryResult.forms;
-        return forms.isEmpty() ? new Data() : forms.get(0);
+        final var serviceDiscoveryResult = getServiceDiscoveryResult();
+        return serviceDiscoveryResult == null
+                ? null
+                : serviceDiscoveryResult.getServiceDiscoveryExtension(
+                        "http://jabber.org/protocol/muc#roominfo");
     }
 
     public String getAvatar() {
@@ -152,8 +162,9 @@ public class MucOptions {
     }
 
     public boolean hasFeature(String feature) {
-        return this.serviceDiscoveryResult != null
-                && this.serviceDiscoveryResult.features.contains(feature);
+        final var serviceDiscoveryResult = getServiceDiscoveryResult();
+        return serviceDiscoveryResult != null
+                && serviceDiscoveryResult.getFeatureStrings().contains(feature);
     }
 
     public boolean hasVCards() {
@@ -211,9 +222,10 @@ public class MucOptions {
         return conversation.getBooleanAttribute(Conversation.ATTRIBUTE_MEMBERS_ONLY, false);
     }
 
-    public List<String> getFeatures() {
-        return this.serviceDiscoveryResult != null
-                ? this.serviceDiscoveryResult.features
+    public Collection<String> getFeatures() {
+        final var serviceDiscoveryResult = getServiceDiscoveryResult();
+        return serviceDiscoveryResult != null
+                ? serviceDiscoveryResult.getFeatureStrings()
                 : Collections.emptyList();
     }
 
  
  
  
    
    @@ -1,101 +0,0 @@
-package eu.siacs.conversations.entities;
-
-import androidx.annotation.NonNull;
-
-import java.util.Locale;
-
-import eu.siacs.conversations.xml.Element;
-
-public class Presence implements Comparable<Presence> {
-
-	public enum Status {
-		CHAT, ONLINE, AWAY, XA, DND, OFFLINE;
-
-		public String toShowString() {
-			switch(this) {
-				case CHAT: return "chat";
-				case AWAY: return "away";
-				case XA:   return "xa";
-				case DND:  return "dnd";
-			}
-			return null;
-		}
-
-		public static Status fromShowString(String show) {
-			if (show == null) {
-				return ONLINE;
-			} else {
-				switch (show.toLowerCase(Locale.US)) {
-					case "away":
-						return AWAY;
-					case "xa":
-						return XA;
-					case "dnd":
-						return DND;
-					case "chat":
-						return CHAT;
-					default:
-						return ONLINE;
-				}
-			}
-		}
-	}
-
-	private final Status status;
-	private ServiceDiscoveryResult disco;
-	private final String ver;
-	private final String hash;
-	private final String node;
-	private final String message;
-
-	private Presence(Status status, String ver, String hash, String node, String message) {
-		this.status = status;
-		this.ver = ver;
-		this.hash = hash;
-		this.node = node;
-		this.message = message;
-	}
-
-	public static Presence parse(String show, Element caps, String message) {
-		final String hash = caps == null ? null : caps.getAttribute("hash");
-		final String ver = caps == null ? null : caps.getAttribute("ver");
-		final String node = caps == null ? null : caps.getAttribute("node");
-		return new Presence(Status.fromShowString(show), ver, hash, node, message);
-	}
-
-	public int compareTo(@NonNull Presence other) {
-		return this.status.compareTo(other.status);
-	}
-
-	public Status getStatus() {
-		return this.status;
-	}
-
-	public boolean hasCaps() {
-		return ver != null && hash != null;
-	}
-
-	public String getVer() {
-		return this.ver;
-	}
-
-	public String getNode() {
-		return this.node;
-	}
-
-	public String getHash() {
-		return this.hash;
-	}
-
-	public String getMessage() {
-		return this.message;
-	}
-
-	public void setServiceDiscoveryResult(ServiceDiscoveryResult disco) {
-		this.disco = disco;
-	}
-
-	public ServiceDiscoveryResult getServiceDiscoveryResult() {
-		return disco;
-	}
-}
  
  
  
    
    @@ -2,6 +2,7 @@ package eu.siacs.conversations.entities;
 
 import android.content.ContentValues;
 import android.database.Cursor;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Objects;
 
 public class PresenceTemplate extends AbstractEntity {
@@ -13,9 +14,9 @@ public class PresenceTemplate extends AbstractEntity {
 
     private long lastUsed = 0;
     private String statusMessage;
-    private Presence.Status status = Presence.Status.ONLINE;
+    private Presence.Availability status = Presence.Availability.ONLINE;
 
-    public PresenceTemplate(Presence.Status status, String statusMessage) {
+    public PresenceTemplate(Presence.Availability status, String statusMessage) {
         this.status = status;
         this.statusMessage = statusMessage;
         this.lastUsed = System.currentTimeMillis();
@@ -41,11 +42,11 @@ public class PresenceTemplate extends AbstractEntity {
         template.lastUsed = cursor.getLong(cursor.getColumnIndex(LAST_USED));
         template.statusMessage = cursor.getString(cursor.getColumnIndex(MESSAGE));
         template.status =
-                Presence.Status.fromShowString(cursor.getString(cursor.getColumnIndex(STATUS)));
+                Presence.Availability.valueOfShown(cursor.getString(cursor.getColumnIndex(STATUS)));
         return template;
     }
 
-    public Presence.Status getStatus() {
+    public Presence.Availability getStatus() {
         return status;
     }
 
  
  
  
    
    @@ -1,15 +1,25 @@
 package eu.siacs.conversations.entities;
 
 import android.util.Pair;
-
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import im.conversations.android.xmpp.model.disco.info.Identity;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.Hashtable;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 public class Presences {
-    private final Hashtable<String, Presence> presences = new Hashtable<>();
+    private final HashMap<String, Presence> presences = new HashMap<>();
+    private final Contact contact;
+
+    public Presences(final Contact contact) {
+        this.contact = contact;
+    }
 
     private static String nameWithoutVersion(String name) {
         String[] parts = name.split(" ");
@@ -63,18 +73,19 @@ public class Presences {
         }
     }
 
-    public Presence.Status getShownStatus() {
-        Presence.Status status = Presence.Status.OFFLINE;
+    public Presence.Availability getShownStatus() {
+        Presence.Availability highestAvailability = Presence.Availability.OFFLINE;
         synchronized (this.presences) {
-            for (Presence p : presences.values()) {
-                if (p.getStatus() == Presence.Status.DND) {
-                    return p.getStatus();
-                } else if (p.getStatus().compareTo(status) < 0) {
-                    status = p.getStatus();
+            for (final Presence p : presences.values()) {
+                final var availability = p.getAvailability();
+                if (availability == Presence.Availability.DND) {
+                    return availability;
+                } else if (availability.compareTo(highestAvailability) < 0) {
+                    highestAvailability = availability;
                 }
             }
         }
-        return status;
+        return highestAvailability;
     }
 
     public int size() {
@@ -100,10 +111,12 @@ public class Presences {
     public List<PresenceTemplate> asTemplates() {
         synchronized (this.presences) {
             ArrayList<PresenceTemplate> templates = new ArrayList<>(presences.size());
-            for (Presence p : presences.values()) {
-                if (p.getMessage() != null && !p.getMessage().trim().isEmpty()) {
-                    templates.add(new PresenceTemplate(p.getStatus(), p.getMessage()));
+            for (Presence presence : this.presences.values()) {
+                String message = Strings.nullToEmpty(presence.getStatus()).trim();
+                if (Strings.isNullOrEmpty(message)) {
+                    continue;
                 }
+                templates.add(new PresenceTemplate(presence.getAvailability(), message));
             }
             return templates;
         }
@@ -115,24 +128,35 @@ public class Presences {
         }
     }
 
-    public List<String> getStatusMessages() {
-        ArrayList<String> messages = new ArrayList<>();
+    public Set<String> getStatusMessages() {
+        Set<String> messages = new HashSet<>();
         synchronized (this.presences) {
             for (Presence presence : this.presences.values()) {
-                String message = presence.getMessage() == null ? null : presence.getMessage().trim();
-                if (message != null && !message.isEmpty() && !messages.contains(message)) {
-                    messages.add(message);
+                String message = Strings.nullToEmpty(presence.getStatus()).trim();
+                if (Strings.isNullOrEmpty(message)) {
+                    continue;
                 }
+                messages.add(message);
             }
         }
         return messages;
     }
 
     public boolean allOrNonSupport(String namespace) {
+        final var connection = this.contact.getAccount().getXmppConnection();
+        if (connection == null) {
+            return true;
+        }
         synchronized (this.presences) {
-            for (Presence presence : this.presences.values()) {
-                ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
-                if (disco == null || !disco.getFeatures().contains(namespace)) {
+            for (var resource : this.presences.keySet()) {
+                final var disco =
+                        connection
+                                .getManager(DiscoManager.class)
+                                .get(
+                                        Strings.isNullOrEmpty(resource)
+                                                ? contact.getJid().asBareJid()
+                                                : contact.getJid().withResource(resource));
+                if (disco == null || !disco.getFeatureStrings().contains(namespace)) {
                     return false;
                 }
             }
@@ -140,19 +164,28 @@ public class Presences {
         return true;
     }
 
-
     public Pair<Map<String, String>, Map<String, String>> toTypeAndNameMap() {
         Map<String, String> typeMap = new HashMap<>();
         Map<String, String> nameMap = new HashMap<>();
+        final var connection = this.contact.getAccount().getXmppConnection();
+        if (connection == null) {
+            return new Pair<>(typeMap, nameMap);
+        }
         synchronized (this.presences) {
-            for (Map.Entry<String, Presence> presenceEntry : this.presences.entrySet()) {
-                String resource = presenceEntry.getKey();
-                Presence presence = presenceEntry.getValue();
-                ServiceDiscoveryResult serviceDiscoveryResult = presence == null ? null : presence.getServiceDiscoveryResult();
-                if (serviceDiscoveryResult != null && serviceDiscoveryResult.getIdentities().size() > 0) {
-                    ServiceDiscoveryResult.Identity identity = serviceDiscoveryResult.getIdentities().get(0);
+            for (final String resource : this.presences.keySet()) {
+                final var serviceDiscoveryResult =
+                        connection
+                                .getManager(DiscoManager.class)
+                                .get(
+                                        Strings.isNullOrEmpty(resource)
+                                                ? contact.getJid().asBareJid()
+                                                : contact.getJid().withResource(resource));
+                if (serviceDiscoveryResult != null
+                        && !serviceDiscoveryResult.getIdentities().isEmpty()) {
+                    final Identity identity =
+                            Iterables.getFirst(serviceDiscoveryResult.getIdentities(), null);
                     String type = identity.getType();
-                    String name = identity.getName();
+                    String name = identity.getIdentityName();
                     if (type != null) {
                         typeMap.put(resource, type);
                     }
  
  
  
    
    @@ -1,349 +0,0 @@
-package eu.siacs.conversations.entities;
-
-import android.content.ContentValues;
-import android.database.Cursor;
-import android.util.Base64;
-import com.google.common.base.Strings;
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.forms.Data;
-import eu.siacs.conversations.xmpp.forms.Field;
-import im.conversations.android.xmpp.model.stanza.Iq;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-public class ServiceDiscoveryResult {
-    public static final String TABLENAME = "discovery_results";
-    public static final String HASH = "hash";
-    public static final String VER = "ver";
-    public static final String RESULT = "result";
-    protected final String hash;
-    protected final byte[] ver;
-    protected final List<String> features;
-    protected final List<Data> forms;
-    private final List<Identity> identities;
-
-    public ServiceDiscoveryResult(final Iq packet) {
-        this.identities = new ArrayList<>();
-        this.features = new ArrayList<>();
-        this.forms = new ArrayList<>();
-        this.hash = "sha-1"; // We only support sha-1 for now
-
-        final List<Element> elements = packet.query().getChildren();
-
-        for (final Element element : elements) {
-            if (element.getName().equals("identity")) {
-                Identity id = new Identity(element);
-                if (id.getType() != null && id.getCategory() != null) {
-                    identities.add(id);
-                }
-            } else if (element.getName().equals("feature")) {
-                if (element.getAttribute("var") != null) {
-                    features.add(element.getAttribute("var"));
-                }
-            } else if (element.getName().equals("x")
-                    && element.getAttribute("xmlns").equals(Namespace.DATA)) {
-                forms.add(Data.parse(element));
-            }
-        }
-        this.ver = this.mkCapHash();
-    }
-
-    private ServiceDiscoveryResult(String hash, byte[] ver, JSONObject o) throws JSONException {
-        this.identities = new ArrayList<>();
-        this.features = new ArrayList<>();
-        this.forms = new ArrayList<>();
-        this.hash = hash;
-        this.ver = ver;
-
-        JSONArray identities = o.optJSONArray("identities");
-        if (identities != null) {
-            for (int i = 0; i < identities.length(); i++) {
-                this.identities.add(new Identity(identities.getJSONObject(i)));
-            }
-        }
-        JSONArray features = o.optJSONArray("features");
-        if (features != null) {
-            for (int i = 0; i < features.length(); i++) {
-                this.features.add(features.getString(i));
-            }
-        }
-        JSONArray forms = o.optJSONArray("forms");
-        if (forms != null) {
-            for (int i = 0; i < forms.length(); i++) {
-                this.forms.add(createFormFromJSONObject(forms.getJSONObject(i)));
-            }
-        }
-    }
-
-    private ServiceDiscoveryResult() {
-        this.hash = "sha-1";
-        this.features = Collections.emptyList();
-        this.identities = Collections.emptyList();
-        this.ver = null;
-        this.forms = Collections.emptyList();
-    }
-
-    public static ServiceDiscoveryResult empty() {
-        return new ServiceDiscoveryResult();
-    }
-
-    public ServiceDiscoveryResult(Cursor cursor) throws JSONException {
-        this(
-                cursor.getString(cursor.getColumnIndexOrThrow(HASH)),
-                Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(VER)), Base64.DEFAULT),
-                new JSONObject(cursor.getString(cursor.getColumnIndexOrThrow(RESULT))));
-    }
-
-    private static String clean(String s) {
-        return s.replace("<", "<");
-    }
-
-    private static String blankNull(String s) {
-        return s == null ? "" : clean(s);
-    }
-
-    private static Data createFormFromJSONObject(JSONObject o) {
-        Data data = new Data();
-        JSONArray names = o.names();
-        for (int i = 0; i < names.length(); ++i) {
-            try {
-                String name = names.getString(i);
-                JSONArray jsonValues = o.getJSONArray(name);
-                ArrayList<String> values = new ArrayList<>(jsonValues.length());
-                for (int j = 0; j < jsonValues.length(); ++j) {
-                    values.add(jsonValues.getString(j));
-                }
-                data.put(name, values);
-            } catch (Exception e) {
-                e.printStackTrace();
-            }
-        }
-        return data;
-    }
-
-    private static JSONObject createJSONFromForm(Data data) {
-        JSONObject object = new JSONObject();
-        for (Field field : data.getFields()) {
-            try {
-                JSONArray jsonValues = new JSONArray();
-                for (String value : field.getValues()) {
-                    jsonValues.put(value);
-                }
-                object.put(field.getFieldName(), jsonValues);
-            } catch (Exception e) {
-                e.printStackTrace();
-            }
-        }
-        try {
-            JSONArray jsonValues = new JSONArray();
-            jsonValues.put(data.getFormType());
-            object.put(Data.FORM_TYPE, jsonValues);
-        } catch (Exception e) {
-            e.printStackTrace();
-        }
-        return object;
-    }
-
-    public String getVer() {
-        return Base64.encodeToString(this.ver, Base64.NO_WRAP);
-    }
-
-    public List<Identity> getIdentities() {
-        return this.identities;
-    }
-
-    public List<String> getFeatures() {
-        return this.features;
-    }
-
-    public boolean hasIdentity(String category, String type) {
-        for (Identity id : this.getIdentities()) {
-            if ((category == null || id.getCategory().equals(category))
-                    && (type == null || id.getType().equals(type))) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    public String getExtendedDiscoInformation(final String formType, final String name) {
-        for (final Data form : this.forms) {
-            if (formType.equals(form.getFormType())) {
-                for (final Field field : form.getFields()) {
-                    if (name.equals(field.getFieldName())) {
-                        return field.getValue();
-                    }
-                }
-            }
-        }
-        return null;
-    }
-
-    private byte[] mkCapHash() {
-        StringBuilder s = new StringBuilder();
-
-        List<Identity> identities = this.getIdentities();
-        Collections.sort(identities);
-
-        for (Identity id : identities) {
-            s.append(blankNull(id.getCategory()))
-                    .append("/")
-                    .append(blankNull(id.getType()))
-                    .append("/")
-                    .append(blankNull(id.getLang()))
-                    .append("/")
-                    .append(blankNull(id.getName()))
-                    .append("<");
-        }
-
-        final List<String> features = this.getFeatures();
-        Collections.sort(features);
-        for (final String feature : features) {
-            s.append(clean(feature)).append("<");
-        }
-
-        Collections.sort(forms, Comparator.comparing(Data::getFormType));
-        for (final Data form : forms) {
-            s.append(clean(form.getFormType())).append("<");
-            final List<Field> fields = form.getFields();
-            Collections.sort(
-                    fields, Comparator.comparing(lhs -> Strings.nullToEmpty(lhs.getFieldName())));
-            for (final Field field : fields) {
-                s.append(Strings.nullToEmpty(field.getFieldName())).append("<");
-                final List<String> values = field.getValues();
-                Collections.sort(values, Comparator.comparing(ServiceDiscoveryResult::blankNull));
-                for (final String value : values) {
-                    s.append(blankNull(value)).append("<");
-                }
-            }
-        }
-
-        MessageDigest md;
-        try {
-            md = MessageDigest.getInstance("SHA-1");
-        } catch (NoSuchAlgorithmException e) {
-            return null;
-        }
-
-        return md.digest(s.toString().getBytes(StandardCharsets.UTF_8));
-    }
-
-    private JSONObject toJSON() {
-        try {
-            JSONObject o = new JSONObject();
-
-            JSONArray ids = new JSONArray();
-            for (Identity id : this.getIdentities()) {
-                ids.put(id.toJSON());
-            }
-            o.put("identities", ids);
-
-            o.put("features", new JSONArray(this.getFeatures()));
-
-            JSONArray forms = new JSONArray();
-            for (Data data : this.forms) {
-                forms.put(createJSONFromForm(data));
-            }
-            o.put("forms", forms);
-
-            return o;
-        } catch (JSONException e) {
-            return null;
-        }
-    }
-
-    public ContentValues getContentValues() {
-        final ContentValues values = new ContentValues();
-        values.put(HASH, this.hash);
-        values.put(VER, getVer());
-        JSONObject jsonObject = toJSON();
-        values.put(RESULT, jsonObject == null ? "" : jsonObject.toString());
-        return values;
-    }
-
-    public static class Identity implements Comparable<Identity> {
-        protected final String type;
-        protected final String lang;
-        protected final String name;
-        final String category;
-
-        Identity(final String category, final String type, final String lang, final String name) {
-            this.category = category;
-            this.type = type;
-            this.lang = lang;
-            this.name = name;
-        }
-
-        Identity(final Element el) {
-            this(
-                    el.getAttribute("category"),
-                    el.getAttribute("type"),
-                    el.getAttribute("xml:lang"),
-                    el.getAttribute("name"));
-        }
-
-        Identity(final JSONObject o) {
-
-            this(
-                    o.optString("category", null),
-                    o.optString("type", null),
-                    o.optString("lang", null),
-                    o.optString("name", null));
-        }
-
-        public String getCategory() {
-            return this.category;
-        }
-
-        public String getType() {
-            return this.type;
-        }
-
-        public String getLang() {
-            return this.lang;
-        }
-
-        public String getName() {
-            return this.name;
-        }
-
-        JSONObject toJSON() {
-            try {
-                JSONObject o = new JSONObject();
-                o.put("category", this.getCategory());
-                o.put("type", this.getType());
-                o.put("lang", this.getLang());
-                o.put("name", this.getName());
-                return o;
-            } catch (JSONException e) {
-                return null;
-            }
-        }
-
-        @Override
-        public int compareTo(final Identity o) {
-            int r = blankNull(this.getCategory()).compareTo(blankNull(o.getCategory()));
-            if (r == 0) {
-                r = blankNull(this.getType()).compareTo(blankNull(o.getType()));
-            }
-            if (r == 0) {
-                r = blankNull(this.getLang()).compareTo(blankNull(o.getLang()));
-            }
-            if (r == 0) {
-                r = blankNull(this.getName()).compareTo(blankNull(o.getName()));
-            }
-
-            return r;
-        }
-    }
-}
  
  
  
    
    @@ -1,14 +1,13 @@
 package eu.siacs.conversations.generator;
 
 import android.text.TextUtils;
-
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.xmpp.model.stanza.Presence;
 
 public class PresenceGenerator extends AbstractGenerator {
 
@@ -16,20 +15,25 @@ public class PresenceGenerator extends AbstractGenerator {
         super(service);
     }
 
-    private im.conversations.android.xmpp.model.stanza.Presence subscription(String type, Contact contact) {
-        im.conversations.android.xmpp.model.stanza.Presence packet = new im.conversations.android.xmpp.model.stanza.Presence();
+    private im.conversations.android.xmpp.model.stanza.Presence subscription(
+            String type, Contact contact) {
+        im.conversations.android.xmpp.model.stanza.Presence packet =
+                new im.conversations.android.xmpp.model.stanza.Presence();
         packet.setAttribute("type", type);
         packet.setTo(contact.getJid());
         packet.setFrom(contact.getAccount().getJid().asBareJid());
         return packet;
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence requestPresenceUpdatesFrom(final Contact contact) {
+    public im.conversations.android.xmpp.model.stanza.Presence requestPresenceUpdatesFrom(
+            final Contact contact) {
         return requestPresenceUpdatesFrom(contact, null);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence requestPresenceUpdatesFrom(final Contact contact, final String preAuth) {
-        im.conversations.android.xmpp.model.stanza.Presence packet = subscription("subscribe", contact);
+    public im.conversations.android.xmpp.model.stanza.Presence requestPresenceUpdatesFrom(
+            final Contact contact, final String preAuth) {
+        im.conversations.android.xmpp.model.stanza.Presence packet =
+                subscription("subscribe", contact);
         String displayName = contact.getAccount().getDisplayName();
         if (!TextUtils.isEmpty(displayName)) {
             packet.addChild("nick", Namespace.NICK).setContent(displayName);
@@ -40,41 +44,42 @@ public class PresenceGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence stopPresenceUpdatesFrom(Contact contact) {
+    public im.conversations.android.xmpp.model.stanza.Presence stopPresenceUpdatesFrom(
+            Contact contact) {
         return subscription("unsubscribe", contact);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence stopPresenceUpdatesTo(Contact contact) {
+    public im.conversations.android.xmpp.model.stanza.Presence stopPresenceUpdatesTo(
+            Contact contact) {
         return subscription("unsubscribed", contact);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence sendPresenceUpdatesTo(Contact contact) {
+    public im.conversations.android.xmpp.model.stanza.Presence sendPresenceUpdatesTo(
+            Contact contact) {
         return subscription("subscribed", contact);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(Account account, Presence.Status status) {
+    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(
+            Account account, Presence.Availability status) {
         return selfPresence(account, status, true);
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(final Account account, final Presence.Status status, final boolean personal) {
-        final im.conversations.android.xmpp.model.stanza.Presence packet = new im.conversations.android.xmpp.model.stanza.Presence();
+    public im.conversations.android.xmpp.model.stanza.Presence selfPresence(
+            final Account account, final Presence.Availability status, final boolean personal) {
+        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();
-            if (status.toShowString() != null) {
-                packet.addChild("show").setContent(status.toShowString());
-            }
-            if (!TextUtils.isEmpty(message)) {
-                packet.addChild(new Element("status").setContent(message));
-            }
+            packet.setAvailability(status);
+            packet.setStatus(message);
             if (sig != null && mXmppConnectionService.getPgpEngine() != null) {
                 packet.addChild("x", "jabber:x:signed").setContent(sig);
             }
         }
         final String capHash = getCapHash(account);
         if (capHash != null) {
-            Element cap = packet.addChild("c",
-                    "http://jabber.org/protocol/caps");
+            Element cap = packet.addChild("c", "http://jabber.org/protocol/caps");
             cap.setAttribute("hash", "sha-1");
             cap.setAttribute("node", "http://conversations.im");
             cap.setAttribute("ver", capHash);
@@ -83,15 +88,18 @@ public class PresenceGenerator extends AbstractGenerator {
     }
 
     public im.conversations.android.xmpp.model.stanza.Presence leave(final MucOptions mucOptions) {
-        im.conversations.android.xmpp.model.stanza.Presence presence = new im.conversations.android.xmpp.model.stanza.Presence();
+        im.conversations.android.xmpp.model.stanza.Presence presence =
+                new im.conversations.android.xmpp.model.stanza.Presence();
         presence.setTo(mucOptions.getSelf().getFullJid());
         presence.setFrom(mucOptions.getAccount().getJid());
         presence.setAttribute("type", "unavailable");
         return presence;
     }
 
-    public im.conversations.android.xmpp.model.stanza.Presence sendOfflinePresence(Account account) {
-        im.conversations.android.xmpp.model.stanza.Presence packet = new im.conversations.android.xmpp.model.stanza.Presence();
+    public im.conversations.android.xmpp.model.stanza.Presence sendOfflinePresence(
+            Account account) {
+        im.conversations.android.xmpp.model.stanza.Presence packet =
+                new im.conversations.android.xmpp.model.stanza.Presence();
         packet.setFrom(account.getJid());
         packet.setAttribute("type", "unavailable");
         return packet;
  
  
  
    
    @@ -1,7 +1,12 @@
 package eu.siacs.conversations.parser;
 
 import android.util.Log;
+import androidx.annotation.NonNull;
 import com.google.common.base.Strings;
+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.crypto.PgpEngine;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
@@ -10,7 +15,6 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.generator.PresenceGenerator;
 import eu.siacs.conversations.services.XmppConnectionService;
@@ -18,10 +22,13 @@ 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.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
+import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
 import org.openintents.openpgp.util.OpenPgpUtils;
 
@@ -359,13 +366,17 @@ public class PresenceParser extends AbstractParser
 
             final int sizeBefore = contact.getPresences().size();
 
-            final String show = packet.findChildContent("show");
-            final Element caps = packet.findChild("c", "http://jabber.org/protocol/caps");
-            final String message = packet.findChildContent("status");
-            final Presence presence = Presence.parse(show, caps, message);
-            contact.updatePresence(resource, presence);
-            if (presence.hasCaps()) {
-                mXmppConnectionService.fetchCaps(account, from, presence);
+            contact.updatePresence(resource, packet);
+
+            final var nodeHash = packet.getCapabilities();
+            final var connection = account.getXmppConnection();
+            if (nodeHash != null && connection != null) {
+                final var discoFuture =
+                        connection
+                                .getManager(DiscoManager.class)
+                                .infoOrCache(Entity.presence(from), nodeHash.node, nodeHash.hash);
+
+                logDiscoFailure(from, discoFuture);
             }
 
             final Element idle = packet.findChild("idle", Namespace.IDLE);
@@ -412,7 +423,8 @@ public class PresenceParser extends AbstractParser
             } else {
                 contact.removePresence(from.getResource());
             }
-            if (contact.getShownStatus() == Presence.Status.OFFLINE) {
+            if (contact.getShownStatus()
+                    == im.conversations.android.xmpp.model.stanza.Presence.Availability.OFFLINE) {
                 contact.flagInactive();
             }
             mXmppConnectionService.onContactStatusChanged.onContactStatusChanged(contact, false);
@@ -453,6 +465,24 @@ public class PresenceParser extends AbstractParser
         mXmppConnectionService.updateRosterUi();
     }
 
+    private static void logDiscoFailure(final Jid from, ListenableFuture<Void> discoFuture) {
+        Futures.addCallback(
+                discoFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Void result) {}
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        if (throwable instanceof TimeoutException) {
+                            return;
+                        }
+                        Log.d(Config.LOGTAG, "could not retrieve disco from " + from, throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
     @Override
     public void accept(final im.conversations.android.xmpp.model.stanza.Presence packet) {
         if (packet.hasChild("x", Namespace.MUC_USER)) {
  
  
  
    
    @@ -21,7 +21,6 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.PresenceTemplate;
 import eu.siacs.conversations.entities.Roster;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.ShortcutService;
 import eu.siacs.conversations.utils.CryptoHelper;
@@ -31,9 +30,14 @@ import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.mam.MamReference;
+import im.conversations.android.xml.XmlElementReader;
+import im.conversations.android.xmpp.EntityCapabilities;
+import im.conversations.android.xmpp.EntityCapabilities2;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.security.cert.CertificateEncodingException;
 import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
@@ -46,7 +50,6 @@ import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArrayList;
-import org.json.JSONException;
 import org.json.JSONObject;
 import org.jxmpp.jid.parts.Localpart;
 import org.jxmpp.stringprep.XmppStringprepException;
@@ -61,11 +64,11 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 public class DatabaseBackend extends SQLiteOpenHelper {
 
     private static final String DATABASE_NAME = "history";
-    private static final int DATABASE_VERSION = 53;
+    private static final int DATABASE_VERSION = 54;
 
     private static boolean requiresMessageIndexRebuild = false;
     private static DatabaseBackend instance = null;
-    private static final String CREATE_CONTATCS_STATEMENT =
+    private static final String CREATE_CONTACTS_STATEMENT =
             "create table "
                     + Contact.TABLENAME
                     + "("
@@ -108,22 +111,6 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                     + Contact.JID
                     + ") ON CONFLICT REPLACE);";
 
-    private static final String CREATE_DISCOVERY_RESULTS_STATEMENT =
-            "create table "
-                    + ServiceDiscoveryResult.TABLENAME
-                    + "("
-                    + ServiceDiscoveryResult.HASH
-                    + " TEXT, "
-                    + ServiceDiscoveryResult.VER
-                    + " TEXT, "
-                    + ServiceDiscoveryResult.RESULT
-                    + " TEXT, "
-                    + "UNIQUE("
-                    + ServiceDiscoveryResult.HASH
-                    + ", "
-                    + ServiceDiscoveryResult.VER
-                    + ") ON CONFLICT REPLACE);";
-
     private static final String CREATE_PRESENCE_TEMPLATES_STATEMENT =
             "CREATE TABLE "
                     + PresenceTemplate.TABELNAME
@@ -252,6 +239,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                     + ") ON CONFLICT IGNORE"
                     + ");";
 
+    private static final String CREATE_CAPS_CACHE_TABLE =
+            "CREATE TABLE caps_cache (caps TEXT, caps2 TEXT, disco_info TEXT, UNIQUE (caps), UNIQUE"
+                    + " (caps2));";
+    private static final String CREATE_CAPS_CACHE_INDEX_CAPS =
+            "CREATE INDEX idx_caps ON caps_cache(caps);";
+    private static final String CREATE_CAPS_CACHE_INDEX_CAPS2 =
+            "CREATE INDEX idx_caps2 ON caps_cache(caps2);";
+
     private static final String RESOLVER_RESULTS_TABLENAME = "resolver_results";
 
     private static final String CREATE_RESOLVER_RESULTS_TABLE =
@@ -495,8 +490,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.execSQL(CREATE_MESSAGE_DELETED_INDEX);
         db.execSQL(CREATE_MESSAGE_RELATIVE_FILE_PATH_INDEX);
         db.execSQL(CREATE_MESSAGE_TYPE_INDEX);
-        db.execSQL(CREATE_CONTATCS_STATEMENT);
-        db.execSQL(CREATE_DISCOVERY_RESULTS_STATEMENT);
+        db.execSQL(CREATE_CONTACTS_STATEMENT);
         db.execSQL(CREATE_SESSIONS_STATEMENT);
         db.execSQL(CREATE_PREKEYS_STATEMENT);
         db.execSQL(CREATE_SIGNED_PREKEYS_STATEMENT);
@@ -507,6 +501,9 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.execSQL(CREATE_MESSAGE_INSERT_TRIGGER);
         db.execSQL(CREATE_MESSAGE_UPDATE_TRIGGER);
         db.execSQL(CREATE_MESSAGE_DELETE_TRIGGER);
+        db.execSQL(CREATE_CAPS_CACHE_TABLE);
+        db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS);
+        db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS2);
     }
 
     @Override
@@ -527,7 +524,7 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         }
         if (oldVersion < 5 && newVersion >= 5) {
             db.execSQL("DROP TABLE " + Contact.TABLENAME);
-            db.execSQL(CREATE_CONTATCS_STATEMENT);
+            db.execSQL(CREATE_CONTACTS_STATEMENT);
             db.execSQL("UPDATE " + Account.TABLENAME + " SET " + Account.ROSTERVERSION + " = NULL");
         }
         if (oldVersion < 6 && newVersion >= 6) {
@@ -727,10 +724,6 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                             + SQLiteAxolotlStore.CERTIFICATE);
         }
 
-        if (oldVersion < 23 && newVersion >= 23) {
-            db.execSQL(CREATE_DISCOVERY_RESULTS_STATEMENT);
-        }
-
         if (oldVersion < 24 && newVersion >= 24) {
             db.execSQL(
                     "ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + Message.EDITED + " TEXT");
@@ -745,10 +738,6 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             db.execSQL(CREATE_PRESENCE_TEMPLATES_STATEMENT);
         }
 
-        if (oldVersion < 27 && newVersion >= 27) {
-            db.execSQL("DELETE FROM " + ServiceDiscoveryResult.TABLENAME);
-        }
-
         if (oldVersion < 28 && newVersion >= 28) {
             canonicalizeJids(db);
         }
@@ -1086,6 +1075,12 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 }
             }
         }
+        if (oldVersion < 54 && newVersion >= 54) {
+            db.execSQL("DROP TABLE discovery_results");
+            db.execSQL(CREATE_CAPS_CACHE_TABLE);
+            db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS);
+            db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS2);
+        }
     }
 
     private void canonicalizeJids(SQLiteDatabase db) {
@@ -1224,40 +1219,6 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.insert(Account.TABLENAME, null, account.getContentValues());
     }
 
-    public void insertDiscoveryResult(ServiceDiscoveryResult result) {
-        SQLiteDatabase db = this.getWritableDatabase();
-        db.insert(ServiceDiscoveryResult.TABLENAME, null, result.getContentValues());
-    }
-
-    public ServiceDiscoveryResult findDiscoveryResult(final String hash, final String ver) {
-        SQLiteDatabase db = this.getReadableDatabase();
-        String[] selectionArgs = {hash, ver};
-        Cursor cursor =
-                db.query(
-                        ServiceDiscoveryResult.TABLENAME,
-                        null,
-                        ServiceDiscoveryResult.HASH + "=? AND " + ServiceDiscoveryResult.VER + "=?",
-                        selectionArgs,
-                        null,
-                        null,
-                        null);
-        if (cursor.getCount() == 0) {
-            cursor.close();
-            return null;
-        }
-        cursor.moveToFirst();
-
-        ServiceDiscoveryResult result = null;
-        try {
-            result = new ServiceDiscoveryResult(cursor);
-        } catch (JSONException e) {
-            /* result is still null */
-        }
-
-        cursor.close();
-        return result;
-    }
-
     public void saveResolverResult(String domain, Resolver.Result result) {
         SQLiteDatabase db = this.getWritableDatabase();
         ContentValues contentValues = result.toContentValues();
@@ -1612,6 +1573,60 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return message;
     }
 
+    public void insertCapsCache(
+            EntityCapabilities.EntityCapsHash caps,
+            EntityCapabilities2.EntityCaps2Hash caps2,
+            InfoQuery infoQuery) {
+        final var contentValues = new ContentValues();
+        contentValues.put("caps", caps.encoded());
+        contentValues.put("caps2", caps2.encoded());
+        contentValues.put("disco_info", infoQuery.toString());
+        getWritableDatabase()
+                .insertWithOnConflict(
+                        "caps_cache", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE);
+    }
+
+    public InfoQuery getInfoQuery(final EntityCapabilities.Hash hash) {
+        final String selection;
+        final String[] args;
+        if (hash instanceof EntityCapabilities.EntityCapsHash) {
+            selection = "caps=?";
+            args = new String[] {hash.encoded()};
+        } else if (hash instanceof EntityCapabilities2.EntityCaps2Hash) {
+            selection = "caps2=?";
+            args = new String[] {hash.encoded()};
+        } else {
+            return null;
+        }
+        try (final Cursor cursor =
+                getReadableDatabase()
+                        .query(
+                                "caps_cache",
+                                new String[] {"disco_info"},
+                                selection,
+                                args,
+                                null,
+                                null,
+                                null)) {
+            if (cursor.moveToFirst()) {
+                final var cached = cursor.getString(0);
+                try {
+                    final var element =
+                            XmlElementReader.read(cached.getBytes(StandardCharsets.UTF_8));
+                    if (element instanceof InfoQuery infoQuery) {
+                        return infoQuery;
+                    }
+                } catch (final IOException e) {
+                    Log.e(Config.LOGTAG, "could not restore info query from cache", e);
+                    return null;
+                }
+            } else {
+                return null;
+            }
+        }
+        return null;
+    }
+
     public static class FilePath {
         public final UUID uuid;
         public final String path;
  
  
  
    
    @@ -19,6 +19,7 @@ import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.stanza.Message;
 import java.math.BigInteger;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
@@ -55,7 +56,7 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
             }
         }
 
-        private static Version get(List<String> features) {
+        private static Version get(final Collection<String> features) {
             final Version[] values = values();
             for (int i = values.length - 1; i >= 0; --i) {
                 for (String feature : features) {
@@ -67,7 +68,7 @@ public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
             return MAM_0;
         }
 
-        public static boolean has(List<String> features) {
+        public static boolean has(final Collection<String> features) {
             for (String feature : features) {
                 for (Version version : values()) {
                     if (version.namespace.equals(feature)) {
  
  
  
    
    @@ -81,11 +81,8 @@ import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.MucOptions.OnRenameListener;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.PresenceTemplate;
 import eu.siacs.conversations.entities.Reaction;
-import eu.siacs.conversations.entities.Roster;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.generator.AbstractGenerator;
 import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.generator.MessageGenerator;
@@ -125,6 +122,7 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.LocalizedContent;
 import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.IqErrorResponseException;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnContactStatusChanged;
 import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
@@ -140,10 +138,13 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 import eu.siacs.conversations.xmpp.mam.MamReference;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import eu.siacs.conversations.xmpp.pep.PublishOptions;
+import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.avatar.Metadata;
 import im.conversations.android.xmpp.model.bookmark.Storage;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.mds.Displayed;
 import im.conversations.android.xmpp.model.pubsub.PubSub;
 import im.conversations.android.xmpp.model.stanza.Iq;
@@ -358,8 +359,6 @@ public class XmppConnectionService extends Service {
     public final Set<String> FILENAMES_TO_IGNORE_DELETION = new HashSet<>();
 
     private final AtomicLong mLastExpiryRun = new AtomicLong(0);
-    private final LruCache<Pair<String, String>, ServiceDiscoveryResult> discoCache =
-            new LruCache<>(20);
     private final OnStatusChanged statusListener =
             new OnStatusChanged() {
 
@@ -1268,13 +1267,13 @@ public class XmppConnectionService extends Service {
                         getResources().getString(R.string.picture_compression));
     }
 
-    private Presence.Status getTargetPresence() {
+    private im.conversations.android.xmpp.model.stanza.Presence.Availability getTargetPresence() {
         if (dndOnSilentMode() && isPhoneSilenced()) {
-            return Presence.Status.DND;
+            return im.conversations.android.xmpp.model.stanza.Presence.Availability.DND;
         } else if (awayWhenScreenLocked() && isScreenLocked()) {
-            return Presence.Status.AWAY;
+            return im.conversations.android.xmpp.model.stanza.Presence.Availability.AWAY;
         } else {
-            return Presence.Status.ONLINE;
+            return im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE;
         }
     }
 
@@ -3721,7 +3720,8 @@ public class XmppConnectionService extends Service {
                             final var packet =
                                     mPresenceGenerator.selfPresence(
                                             account,
-                                            Presence.Status.ONLINE,
+                                            im.conversations.android.xmpp.model.stanza.Presence
+                                                    .Availability.ONLINE,
                                             mucOptions.nonanonymous()
                                                     || onConferenceJoined != null);
                             packet.setTo(joinJid);
@@ -4117,7 +4117,9 @@ public class XmppConnectionService extends Service {
 
             final var packet =
                     mPresenceGenerator.selfPresence(
-                            account, Presence.Status.ONLINE, options.nonanonymous());
+                            account,
+                            im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE,
+                            options.nonanonymous());
             packet.setTo(joinJid);
             sendPresencePacket(account, packet);
             if (nick.equals(MucOptions.defaultNick(account))
@@ -4174,7 +4176,9 @@ public class XmppConnectionService extends Service {
                         account.getJid().asBareJid(), joinJid, current));
         final var packet =
                 mPresenceGenerator.selfPresence(
-                        account, Presence.Status.ONLINE, options.nonanonymous());
+                        account,
+                        im.conversations.android.xmpp.model.stanza.Presence.Availability.ONLINE,
+                        options.nonanonymous());
         packet.setTo(joinJid);
         sendPresencePacket(account, packet);
     }
@@ -4367,11 +4371,19 @@ public class XmppConnectionService extends Service {
             final Conversation conversation, final OnConferenceConfigurationFetched callback) {
         final Iq request = mIqGenerator.queryDiscoInfo(conversation.getJid().asBareJid());
         final var account = conversation.getAccount();
-        sendIqPacket(
-                account,
-                request,
-                response -> {
-                    if (response.getType() == Iq.Type.RESULT) {
+        final var connection = account.getXmppConnection();
+        if (connection == null) {
+            return;
+        }
+        final var future =
+                connection
+                        .getManager(DiscoManager.class)
+                        .info(Entity.discoItem(conversation.getJid().asBareJid()), null);
+        Futures.addCallback(
+                future,
+                new FutureCallback<InfoQuery>() {
+                    @Override
+                    public void onSuccess(InfoQuery result) {
                         final MucOptions mucOptions = conversation.getMucOptions();
                         final Bookmark bookmark = conversation.getBookmark();
                         final boolean sameBefore =
@@ -4380,7 +4392,7 @@ public class XmppConnectionService extends Service {
                                         mucOptions.getName());
 
                         final var hadOccupantId = mucOptions.occupantId();
-                        if (mucOptions.updateConfiguration(new ServiceDiscoveryResult(response))) {
+                        if (mucOptions.updateConfiguration(result)) {
                             Log.d(
                                     Config.LOGTAG,
                                     account.getJid().asBareJid()
@@ -4402,7 +4414,8 @@ public class XmppConnectionService extends Service {
                             final var packet =
                                     mPresenceGenerator.selfPresence(
                                             account,
-                                            Presence.Status.ONLINE,
+                                            im.conversations.android.xmpp.model.stanza.Presence
+                                                    .Availability.ONLINE,
                                             mucOptions.nonanonymous());
                             packet.setTo(me);
                             sendPresencePacket(account, packet);
@@ -4421,18 +4434,27 @@ public class XmppConnectionService extends Service {
                         }
 
                         updateConversationUi();
-                    } else if (response.getType() == Iq.Type.TIMEOUT) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": received timeout waiting for conference configuration"
-                                        + " fetch");
-                    } else {
-                        if (callback != null) {
-                            callback.onFetchFailed(conversation, response.getErrorCondition());
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        if (throwable instanceof TimeoutException) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": received timeout waiting for conference"
+                                            + " configuration fetch");
+                        } else if (throwable
+                                instanceof IqErrorResponseException errorResponseException) {
+                            if (callback != null) {
+                                callback.onFetchFailed(
+                                        conversation,
+                                        errorResponseException.getResponse().getErrorCondition());
+                            }
                         }
                     }
-                });
+                },
+                MoreExecutors.directExecutor());
     }
 
     public void pushNodeConfiguration(
@@ -5270,7 +5292,10 @@ public class XmppConnectionService extends Service {
                     if (mucOptions.online()) {
                         final var packet =
                                 mPresenceGenerator.selfPresence(
-                                        account, Presence.Status.ONLINE, mucOptions.nonanonymous());
+                                        account,
+                                        im.conversations.android.xmpp.model.stanza.Presence
+                                                .Availability.ONLINE,
+                                        mucOptions.nonanonymous());
                         packet.setTo(mucOptions.getSelf().getFullJid());
                         connection.sendPresencePacket(packet);
                     }
@@ -5982,7 +6007,7 @@ public class XmppConnectionService extends Service {
     }
 
     private void sendPresence(final Account account, final boolean includeIdleTimestamp) {
-        final Presence.Status status;
+        final im.conversations.android.xmpp.model.stanza.Presence.Availability status;
         if (manuallyChangePresence()) {
             status = account.getPresenceStatus();
         } else {
@@ -6217,100 +6242,6 @@ public class XmppConnectionService extends Service {
                 });
     }
 
-    public ServiceDiscoveryResult getCachedServiceDiscoveryResult(Pair<String, String> key) {
-        ServiceDiscoveryResult result = discoCache.get(key);
-        if (result != null) {
-            return result;
-        } else {
-            result = databaseBackend.findDiscoveryResult(key.first, key.second);
-            if (result != null) {
-                discoCache.put(key, result);
-            }
-            return result;
-        }
-    }
-
-    public void fetchCaps(final Account account, final Jid jid, final Presence presence) {
-        final Pair<String, String> key = new Pair<>(presence.getHash(), presence.getVer());
-        final ServiceDiscoveryResult disco = getCachedServiceDiscoveryResult(key);
-        if (disco != null) {
-            presence.setServiceDiscoveryResult(disco);
-            final Contact contact = account.getRoster().getContact(jid);
-            if (contact.refreshRtpCapability()) {
-                syncRoster(account);
-            }
-        } else {
-            final Iq request = new Iq(Iq.Type.GET);
-            request.setTo(jid);
-            final String node = presence.getNode();
-            final String ver = presence.getVer();
-            final Element query = request.query(Namespace.DISCO_INFO);
-            if (node != null && ver != null) {
-                query.setAttribute("node", node + "#" + ver);
-            }
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid().asBareJid()
-                            + ": making disco request for "
-                            + key.second
-                            + " to "
-                            + jid);
-            sendIqPacket(
-                    account,
-                    request,
-                    (response) -> {
-                        if (response.getType() == Iq.Type.RESULT) {
-                            final ServiceDiscoveryResult discoveryResult =
-                                    new ServiceDiscoveryResult(response);
-                            if (presence.getVer().equals(discoveryResult.getVer())) {
-                                databaseBackend.insertDiscoveryResult(discoveryResult);
-                                injectServiceDiscoveryResult(
-                                        account.getRoster(),
-                                        presence.getHash(),
-                                        presence.getVer(),
-                                        discoveryResult);
-                            } else {
-                                Log.d(
-                                        Config.LOGTAG,
-                                        account.getJid().asBareJid()
-                                                + ": mismatch in caps for contact "
-                                                + jid
-                                                + " "
-                                                + presence.getVer()
-                                                + " vs "
-                                                + discoveryResult.getVer());
-                            }
-                        } else {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid()
-                                            + ": unable to fetch caps from "
-                                            + jid);
-                        }
-                    });
-        }
-    }
-
-    private void injectServiceDiscoveryResult(
-            Roster roster, String hash, String ver, ServiceDiscoveryResult disco) {
-        boolean rosterNeedsSync = false;
-        for (final Contact contact : roster.getContacts()) {
-            boolean serviceDiscoverySet = false;
-            for (final Presence presence : contact.getPresences().getPresences()) {
-                if (hash.equals(presence.getHash()) && ver.equals(presence.getVer())) {
-                    presence.setServiceDiscoveryResult(disco);
-                    serviceDiscoverySet = true;
-                }
-            }
-            if (serviceDiscoverySet) {
-                rosterNeedsSync |= contact.refreshRtpCapability();
-            }
-        }
-        if (rosterNeedsSync) {
-            syncRoster(roster.getAccount());
-        }
-    }
-
     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);
  
  
  
    
    @@ -32,7 +32,9 @@ import androidx.core.view.ViewCompat;
 import androidx.databinding.DataBindingUtil;
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
@@ -44,7 +46,6 @@ import eu.siacs.conversations.databinding.ActivityContactDetailsBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.ListItem;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.services.AbstractQuickConversationsService;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate;
@@ -69,6 +70,7 @@ import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnKeyStatusUpdated;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -424,11 +426,11 @@ public class ContactDetailsActivity extends OmemoActivity
             binding.detailsSendPresence.setOnCheckedChangeListener(null);
             binding.detailsReceivePresence.setOnCheckedChangeListener(null);
 
-            List<String> statusMessages = contact.getPresences().getStatusMessages();
-            if (statusMessages.size() == 0) {
+            Collection<String> statusMessages = contact.getPresences().getStatusMessages();
+            if (statusMessages.isEmpty()) {
                 binding.statusMessage.setVisibility(View.GONE);
             } else if (statusMessages.size() == 1) {
-                final String message = statusMessages.get(0);
+                final String message = Iterables.getOnlyElement(statusMessages);
                 binding.statusMessage.setVisibility(View.VISIBLE);
                 final Spannable span = new SpannableString(message);
                 if (Emoticons.isOnlyEmoji(message)) {
@@ -440,16 +442,7 @@ public class ContactDetailsActivity extends OmemoActivity
                 }
                 binding.statusMessage.setText(span);
             } else {
-                StringBuilder builder = new StringBuilder();
-                binding.statusMessage.setVisibility(View.VISIBLE);
-                int s = statusMessages.size();
-                for (int i = 0; i < s; ++i) {
-                    builder.append(statusMessages.get(i));
-                    if (i < s - 1) {
-                        builder.append("\n");
-                    }
-                }
-                binding.statusMessage.setText(builder);
+                binding.statusMessage.setText(Joiner.on('\n').join(statusMessages));
             }
 
             if (contact.getOption(Contact.Options.FROM)) {
@@ -592,7 +585,7 @@ public class ContactDetailsActivity extends OmemoActivity
 
         final List<ListItem.Tag> tagList = contact.getTags(this);
         final boolean hasMetaTags =
-                contact.isBlocked() || contact.getShownStatus() != Presence.Status.OFFLINE;
+                contact.isBlocked() || contact.getShownStatus() != Presence.Availability.OFFLINE;
         if ((tagList.isEmpty() && !hasMetaTags) || !this.showDynamicTags) {
             binding.tags.setVisibility(View.GONE);
         } else {
@@ -628,8 +621,8 @@ public class ContactDetailsActivity extends OmemoActivity
                 viewIdBuilder.add(id);
                 binding.tags.addView(tv);
             } else {
-                final Presence.Status status = contact.getShownStatus();
-                if (status != Presence.Status.OFFLINE) {
+                final Presence.Availability status = contact.getShownStatus();
+                if (status != Presence.Availability.OFFLINE) {
                     final TextView tv =
                             (TextView) inflater.inflate(R.layout.item_tag, binding.tags, false);
                     UIHelper.setStatus(tv, status);
  
  
  
    
    @@ -81,7 +81,6 @@ import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.MucOptions.User;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.ReadByMarker;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.entities.TransferablePlaceholder;
@@ -128,6 +127,7 @@ import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -2938,7 +2938,7 @@ public class ConversationFragment extends XmppFragment
         boolean hasAttachments =
                 mediaPreviewAdapter != null && mediaPreviewAdapter.hasAttachments();
         final Conversation c = this.conversation;
-        final Presence.Status status;
+        final Presence.Availability status;
         final String text =
                 this.binding.textinput == null ? "" : this.binding.textinput.getText().toString();
         final SendButtonAction action;
@@ -2951,17 +2951,17 @@ public class ConversationFragment extends XmppFragment
             if (activity != null
                     && activity.xmppConnectionService != null
                     && activity.xmppConnectionService.getMessageArchiveService().isCatchingUp(c)) {
-                status = Presence.Status.OFFLINE;
+                status = Presence.Availability.OFFLINE;
             } else if (c.getMode() == Conversation.MODE_SINGLE) {
                 status = c.getContact().getShownStatus();
             } else {
                 status =
                         c.getMucOptions().online()
-                                ? Presence.Status.ONLINE
-                                : Presence.Status.OFFLINE;
+                                ? Presence.Availability.ONLINE
+                                : Presence.Availability.OFFLINE;
             }
         } else {
-            status = Presence.Status.OFFLINE;
+            status = Presence.Availability.OFFLINE;
         }
         this.binding.textSendButton.setTag(action);
         this.binding.textSendButton.setIconResource(
  
  
  
    
    @@ -51,7 +51,6 @@ import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
 import eu.siacs.conversations.databinding.ActivityEditAccountBinding;
 import eu.siacs.conversations.databinding.DialogPresenceBinding;
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.PresenceTemplate;
 import eu.siacs.conversations.services.BarcodeProvider;
 import eu.siacs.conversations.services.QuickConversationsService;
@@ -80,6 +79,7 @@ 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.pep.Avatar;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -411,7 +411,7 @@ public class EditAccountActivity extends OmemoActivity
             };
 
     private static void setAvailabilityRadioButton(
-            Presence.Status status, DialogPresenceBinding binding) {
+            Presence.Availability status, DialogPresenceBinding binding) {
         if (status == null) {
             binding.online.setChecked(true);
             return;
@@ -431,15 +431,15 @@ public class EditAccountActivity extends OmemoActivity
         }
     }
 
-    private static Presence.Status getAvailabilityRadioButton(DialogPresenceBinding binding) {
+    private static Presence.Availability getAvailabilityRadioButton(DialogPresenceBinding binding) {
         if (binding.dnd.isChecked()) {
-            return Presence.Status.DND;
+            return Presence.Availability.DND;
         } else if (binding.xa.isChecked()) {
-            return Presence.Status.XA;
+            return Presence.Availability.XA;
         } else if (binding.away.isChecked()) {
-            return Presence.Status.AWAY;
+            return Presence.Availability.AWAY;
         } else {
-            return Presence.Status.ONLINE;
+            return Presence.Availability.ONLINE;
         }
     }
 
  
  
  
    
    @@ -68,7 +68,6 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.ListItem;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.services.XmppConnectionService.OnRosterUpdate;
@@ -84,6 +83,7 @@ import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -1152,12 +1152,12 @@ public class StartConversationActivity extends XmppActivity
         for (final Account account : accounts) {
             if (account.isEnabled()) {
                 for (Contact contact : account.getRoster().getContacts()) {
-                    Presence.Status s = contact.getShownStatus();
+                    Presence.Availability s = contact.getShownStatus();
                     if (contact.showInContactList()
                             && contact.match(this, needle)
                             && (!this.mHideOfflineContacts
                                     || (needle != null && !needle.trim().isEmpty())
-                                    || s.compareTo(Presence.Status.OFFLINE) < 0)) {
+                                    || s.compareTo(Presence.Availability.OFFLINE) < 0)) {
                         this.contacts.add(contact);
                     }
                 }
  
  
  
    
    @@ -10,169 +10,170 @@ import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
 import android.widget.ImageView;
 import android.widget.TextView;
-
 import androidx.annotation.NonNull;
 import androidx.constraintlayout.helper.widget.Flow;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.core.content.ContextCompat;
 import androidx.core.view.ViewCompat;
 import androidx.databinding.DataBindingUtil;
-
 import com.google.android.material.color.MaterialColors;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
-
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ItemContactBinding;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.ListItem;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.ui.XmppActivity;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.utils.IrregularUnicodeDetector;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.utils.XEP0392Helper;
 import eu.siacs.conversations.xmpp.Jid;
-
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.List;
 
 public class ListItemAdapter extends ArrayAdapter<ListItem> {
 
-	protected XmppActivity activity;
-	private boolean showDynamicTags = false;
-	private OnTagClickedListener mOnTagClickedListener = null;
-	private final View.OnClickListener onTagTvClick = view -> {
-		if (view instanceof TextView tv && mOnTagClickedListener != null) {
-			final String tag = tv.getText().toString();
-			mOnTagClickedListener.onTagClicked(tag);
-		}
-	};
-
-	public ListItemAdapter(XmppActivity activity, List<ListItem> objects) {
-		super(activity, 0, objects);
-		this.activity = activity;
-	}
-
-
-	public void refreshSettings() {
-		SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity);
-		this.showDynamicTags = preferences.getBoolean(AppSettings.SHOW_DYNAMIC_TAGS, false);
-	}
-
-	@NonNull
-	@Override
-	public View getView(int position, View view, @NonNull ViewGroup parent) {
-		LayoutInflater inflater = activity.getLayoutInflater();
-		final ListItem item = getItem(position);
-		ViewHolder viewHolder;
-		if (view == null) {
-			final ItemContactBinding binding = DataBindingUtil.inflate(inflater,R.layout.item_contact,parent,false);
-			viewHolder = ViewHolder.get(binding);
-			view = binding.getRoot();
-		} else {
-			viewHolder = (ViewHolder) view.getTag();
-		}
-		if (view.isActivated()) {
-			Log.d(Config.LOGTAG,"item "+item.getDisplayName()+" is activated");
-		}
-		//view.setBackground(StyledAttributes.getDrawable(view.getContext(),R.attr.list_item_background));
-		final List<ListItem.Tag> tags = item.getTags(activity);
-		final boolean hasMetaTags;
-		if (item instanceof Contact contact) {
-			hasMetaTags = contact.isBlocked() || contact.getShownStatus() != Presence.Status.OFFLINE;
-		} else {
-			hasMetaTags = false;
-		}
-		if ((tags.isEmpty() && !hasMetaTags) || !this.showDynamicTags) {
-			viewHolder.tags.setVisibility(View.GONE);
-		} else {
-			viewHolder.tags.setVisibility(View.VISIBLE);
-			viewHolder.tags.removeViews(1, viewHolder.tags.getChildCount() - 1);
-			final ImmutableList.Builder<Integer> viewIdBuilder = new ImmutableList.Builder<>();
-			for (final ListItem.Tag tag : tags) {
-				final String name = tag.getName();
-				final TextView tv = (TextView) inflater.inflate(R.layout.item_tag, viewHolder.tags, false);
-				tv.setText(name);
-				tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(getContext(),XEP0392Helper.rgbFromNick(name))));
-				tv.setOnClickListener(this.onTagTvClick);
-				final int id = ViewCompat.generateViewId();
-				tv.setId(id);
-				viewIdBuilder.add(id);
-				viewHolder.tags.addView(tv);
-			}
-			if (item instanceof Contact contact) {
-				if (contact.isBlocked()) {
-					final TextView tv =
-							(TextView)
-									inflater.inflate(
-											R.layout.item_tag, viewHolder.tags, false);
-					tv.setText(R.string.blocked);
-					tv.setBackgroundTintList(ColorStateList.valueOf(MaterialColors.harmonizeWithPrimary(tv.getContext(),ContextCompat.getColor(tv.getContext(),R.color.gray_800))));
-					final int id = ViewCompat.generateViewId();
-					tv.setId(id);
-					viewIdBuilder.add(id);
-					viewHolder.tags.addView(tv);
-				} else {
-                    final Presence.Status status = contact.getShownStatus();
-                    if (status != Presence.Status.OFFLINE) {
+    protected XmppActivity activity;
+    private boolean showDynamicTags = false;
+    private OnTagClickedListener mOnTagClickedListener = null;
+    private final View.OnClickListener onTagTvClick =
+            view -> {
+                if (view instanceof TextView tv && mOnTagClickedListener != null) {
+                    final String tag = tv.getText().toString();
+                    mOnTagClickedListener.onTagClicked(tag);
+                }
+            };
+
+    public ListItemAdapter(XmppActivity activity, List<ListItem> objects) {
+        super(activity, 0, objects);
+        this.activity = activity;
+    }
+
+    public void refreshSettings() {
+        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity);
+        this.showDynamicTags = preferences.getBoolean(AppSettings.SHOW_DYNAMIC_TAGS, false);
+    }
+
+    @NonNull
+    @Override
+    public View getView(int position, View view, @NonNull ViewGroup parent) {
+        LayoutInflater inflater = activity.getLayoutInflater();
+        final ListItem item = getItem(position);
+        ViewHolder viewHolder;
+        if (view == null) {
+            final ItemContactBinding binding =
+                    DataBindingUtil.inflate(inflater, R.layout.item_contact, parent, false);
+            viewHolder = ViewHolder.get(binding);
+            view = binding.getRoot();
+        } else {
+            viewHolder = (ViewHolder) view.getTag();
+        }
+        if (view.isActivated()) {
+            Log.d(Config.LOGTAG, "item " + item.getDisplayName() + " is activated");
+        }
+        // view.setBackground(StyledAttributes.getDrawable(view.getContext(),R.attr.list_item_background));
+        final List<ListItem.Tag> tags = item.getTags(activity);
+        final boolean hasMetaTags;
+        if (item instanceof Contact contact) {
+            hasMetaTags =
+                    contact.isBlocked()
+                            || contact.getShownStatus() != Presence.Availability.OFFLINE;
+        } else {
+            hasMetaTags = false;
+        }
+        if ((tags.isEmpty() && !hasMetaTags) || !this.showDynamicTags) {
+            viewHolder.tags.setVisibility(View.GONE);
+        } else {
+            viewHolder.tags.setVisibility(View.VISIBLE);
+            viewHolder.tags.removeViews(1, viewHolder.tags.getChildCount() - 1);
+            final ImmutableList.Builder<Integer> viewIdBuilder = new ImmutableList.Builder<>();
+            for (final ListItem.Tag tag : tags) {
+                final String name = tag.getName();
+                final TextView tv =
+                        (TextView) inflater.inflate(R.layout.item_tag, viewHolder.tags, false);
+                tv.setText(name);
+                tv.setBackgroundTintList(
+                        ColorStateList.valueOf(
+                                MaterialColors.harmonizeWithPrimary(
+                                        getContext(), XEP0392Helper.rgbFromNick(name))));
+                tv.setOnClickListener(this.onTagTvClick);
+                final int id = ViewCompat.generateViewId();
+                tv.setId(id);
+                viewIdBuilder.add(id);
+                viewHolder.tags.addView(tv);
+            }
+            if (item instanceof Contact contact) {
+                if (contact.isBlocked()) {
+                    final TextView tv =
+                            (TextView) inflater.inflate(R.layout.item_tag, viewHolder.tags, false);
+                    tv.setText(R.string.blocked);
+                    tv.setBackgroundTintList(
+                            ColorStateList.valueOf(
+                                    MaterialColors.harmonizeWithPrimary(
+                                            tv.getContext(),
+                                            ContextCompat.getColor(
+                                                    tv.getContext(), R.color.gray_800))));
+                    final int id = ViewCompat.generateViewId();
+                    tv.setId(id);
+                    viewIdBuilder.add(id);
+                    viewHolder.tags.addView(tv);
+                } else {
+                    final Presence.Availability status = contact.getShownStatus();
+                    if (status != Presence.Availability.OFFLINE) {
                         final TextView tv =
                                 (TextView)
-                                        inflater.inflate(
-                                                R.layout.item_tag, viewHolder.tags, false);
-						UIHelper.setStatus(tv, status);
-						final int id = ViewCompat.generateViewId();
-						tv.setId(id);
-						viewIdBuilder.add(id);
-						viewHolder.tags.addView(tv);
+                                        inflater.inflate(R.layout.item_tag, viewHolder.tags, false);
+                        UIHelper.setStatus(tv, status);
+                        final int id = ViewCompat.generateViewId();
+                        tv.setId(id);
+                        viewIdBuilder.add(id);
+                        viewHolder.tags.addView(tv);
                     }
                 }
-			}
-			viewHolder.flowWidget.setReferencedIds(Ints.toArray(viewIdBuilder.build()));
-		}
-		final Jid jid = item.getJid();
-		if (jid != null) {
-			viewHolder.jid.setVisibility(View.VISIBLE);
-			viewHolder.jid.setText(IrregularUnicodeDetector.style(activity, jid));
-		} else {
-			viewHolder.jid.setVisibility(View.GONE);
-		}
-		viewHolder.name.setText(item.getDisplayName());
-		AvatarWorkerTask.loadAvatar(item, viewHolder.avatar, R.dimen.avatar);
-		return view;
-	}
-
-	public void setOnTagClickedListener(OnTagClickedListener listener) {
-		this.mOnTagClickedListener = listener;
-	}
-
-
-	public interface OnTagClickedListener {
-		void onTagClicked(String tag);
-	}
-
-	private static class ViewHolder {
-		private TextView name;
-		private TextView jid;
-		private ImageView avatar;
-		private ConstraintLayout tags;
-		private Flow flowWidget;
-
-		private ViewHolder() {
-
-		}
-
-		public static ViewHolder get(final ItemContactBinding binding) {
-			ViewHolder viewHolder = new ViewHolder();
-			viewHolder.name = binding.contactDisplayName;
-			viewHolder.jid = binding.contactJid;
-			viewHolder.avatar = binding.contactPhoto;
-			viewHolder.tags = binding.tags;
-			viewHolder.flowWidget = binding.flowWidget;
-			binding.getRoot().setTag(viewHolder);
-			return viewHolder;
-		}
-	}
-
+            }
+            viewHolder.flowWidget.setReferencedIds(Ints.toArray(viewIdBuilder.build()));
+        }
+        final Jid jid = item.getJid();
+        if (jid != null) {
+            viewHolder.jid.setVisibility(View.VISIBLE);
+            viewHolder.jid.setText(IrregularUnicodeDetector.style(activity, jid));
+        } else {
+            viewHolder.jid.setVisibility(View.GONE);
+        }
+        viewHolder.name.setText(item.getDisplayName());
+        AvatarWorkerTask.loadAvatar(item, viewHolder.avatar, R.dimen.avatar);
+        return view;
+    }
+
+    public void setOnTagClickedListener(OnTagClickedListener listener) {
+        this.mOnTagClickedListener = listener;
+    }
+
+    public interface OnTagClickedListener {
+        void onTagClicked(String tag);
+    }
+
+    private static class ViewHolder {
+        private TextView name;
+        private TextView jid;
+        private ImageView avatar;
+        private ConstraintLayout tags;
+        private Flow flowWidget;
+
+        private ViewHolder() {}
+
+        public static ViewHolder get(final ItemContactBinding binding) {
+            ViewHolder viewHolder = new ViewHolder();
+            viewHolder.name = binding.contactDisplayName;
+            viewHolder.jid = binding.contactJid;
+            viewHolder.avatar = binding.contactPhoto;
+            viewHolder.tags = binding.tags;
+            viewHolder.flowWidget = binding.flowWidget;
+            binding.getRoot().setTag(viewHolder);
+            return viewHolder;
+        }
+    }
 }
  
  
  
    
    @@ -31,22 +31,18 @@ package eu.siacs.conversations.ui.util;
 
 import android.app.Activity;
 import android.content.SharedPreferences;
-import android.content.res.Configuration;
 import android.preference.PreferenceManager;
 import android.view.View;
-
 import androidx.annotation.ColorInt;
 import androidx.annotation.DrawableRes;
 import androidx.core.content.ContextCompat;
-
 import com.google.android.material.color.MaterialColors;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Conversation;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.ui.Activities;
 import eu.siacs.conversations.ui.ConversationFragment;
 import eu.siacs.conversations.utils.UIHelper;
+import im.conversations.android.xmpp.model.stanza.Presence;
 
 public class SendButtonTool {
 
@@ -110,28 +106,37 @@ public class SendButtonTool {
         };
     }
 
-    public @ColorInt static int getSendButtonColor(final View view, final Presence.Status status) {
+    public @ColorInt static int getSendButtonColor(
+            final View view, final Presence.Availability status) {
         final boolean nightMode = Activities.isNightMode(view.getContext());
         return switch (status) {
-            case OFFLINE -> MaterialColors.getColor(
-                    view, com.google.android.material.R.attr.colorOnSurface);
-            case ONLINE, CHAT -> MaterialColors.harmonizeWithPrimary(
-                    view.getContext(),
-                    ContextCompat.getColor(
-                            view.getContext(), nightMode ? R.color.green_300 : R.color.green_800));
-            case AWAY -> MaterialColors.harmonizeWithPrimary(
-                    view.getContext(),
-                    ContextCompat.getColor(
-                            view.getContext(), nightMode ? R.color.amber_300 : R.color.amber_800));
-            case XA -> MaterialColors.harmonizeWithPrimary(
-                    view.getContext(),
-                    ContextCompat.getColor(
+            case OFFLINE ->
+                    MaterialColors.getColor(
+                            view, com.google.android.material.R.attr.colorOnSurface);
+            case ONLINE, CHAT ->
+                    MaterialColors.harmonizeWithPrimary(
+                            view.getContext(),
+                            ContextCompat.getColor(
+                                    view.getContext(),
+                                    nightMode ? R.color.green_300 : R.color.green_800));
+            case AWAY ->
+                    MaterialColors.harmonizeWithPrimary(
+                            view.getContext(),
+                            ContextCompat.getColor(
+                                    view.getContext(),
+                                    nightMode ? R.color.amber_300 : R.color.amber_800));
+            case XA ->
+                    MaterialColors.harmonizeWithPrimary(
+                            view.getContext(),
+                            ContextCompat.getColor(
+                                    view.getContext(),
+                                    nightMode ? R.color.orange_300 : R.color.orange_800));
+            case DND ->
+                    MaterialColors.harmonizeWithPrimary(
                             view.getContext(),
-                            nightMode ? R.color.orange_300 : R.color.orange_800));
-            case DND -> MaterialColors.harmonizeWithPrimary(
-                    view.getContext(),
-                    ContextCompat.getColor(
-                            view.getContext(), nightMode ? R.color.red_300 : R.color.red_800));
+                            ContextCompat.getColor(
+                                    view.getContext(),
+                                    nightMode ? R.color.red_300 : R.color.red_800));
         };
     }
 }
  
  
  
    
    @@ -22,12 +22,12 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
-import eu.siacs.conversations.entities.Presence;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.ui.util.QuoteHelper;
 import eu.siacs.conversations.worker.ExportBackupWorker;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
@@ -523,7 +523,7 @@ public class UIHelper {
         return LOCATION_QUESTIONS.contains(body);
     }
 
-    public static void setStatus(final TextView textView, Presence.Status status) {
+    public static void setStatus(final TextView textView, Presence.Availability status) {
         final @StringRes int text;
         final @ColorRes int color =
                 switch (status) {
  
  
  
    
    @@ -1,20 +0,0 @@
-package eu.siacs.conversations.xml;
-
-import com.google.common.io.ByteSource;
-
-import java.io.IOException;
-import java.io.InputStream;
-
-public class XmlElementReader {
-
-    public static Element read(byte[] bytes) throws IOException {
-        return read(ByteSource.wrap(bytes).openStream());
-    }
-
-    public static Element read(InputStream inputStream) throws IOException {
-        final XmlReader xmlReader = new XmlReader();
-        xmlReader.setInputStream(inputStream);
-        return xmlReader.readElement(xmlReader.readTag());
-    }
-
-}
  
  
  
    
    @@ -18,10 +18,16 @@ import com.google.common.base.MoreObjects;
 import com.google.common.base.Optional;
 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;
+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 com.google.common.util.concurrent.SettableFuture;
 import de.gultsch.common.Patterns;
 import eu.siacs.conversations.AppSettings;
@@ -38,12 +44,12 @@ import eu.siacs.conversations.crypto.sasl.SaslMechanism;
 import eu.siacs.conversations.crypto.sasl.ScramMechanism;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.http.HttpConnectionManager;
 import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.parser.MessageParser;
 import eu.siacs.conversations.parser.PresenceParser;
+import eu.siacs.conversations.persistance.DatabaseBackend;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.MemorizingTrustManager;
 import eu.siacs.conversations.services.MessageArchiveService;
@@ -66,6 +72,9 @@ import eu.siacs.conversations.xml.XmlReader;
 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.DiscoManager;
+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;
@@ -75,6 +84,7 @@ import im.conversations.android.xmpp.model.bind2.Bound;
 import im.conversations.android.xmpp.model.cb.SaslChannelBinding;
 import im.conversations.android.xmpp.model.csi.Active;
 import im.conversations.android.xmpp.model.csi.Inactive;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.error.Condition;
 import im.conversations.android.xmpp.model.fast.Fast;
 import im.conversations.android.xmpp.model.fast.RequestToken;
@@ -126,6 +136,7 @@ import java.util.HashSet;
 import java.util.Hashtable;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
@@ -149,7 +160,6 @@ public class XmppConnection implements Runnable {
 
     protected final Account account;
     private final Features features = new Features(this);
-    private final HashMap<Jid, ServiceDiscoveryResult> disco = new HashMap<>();
     private final HashMap<String, Jid> commands = new HashMap<>();
     private final SparseArray<Stanza> mStanzaQueue = new SparseArray<>();
     private final Hashtable<String, Pair<Iq, Consumer<Iq>>> packetCallbacks = new Hashtable<>();
@@ -177,7 +187,6 @@ public class XmppConnection implements Runnable {
     private long lastSessionStarted = 0;
     private long lastDiscoStarted = 0;
     private boolean isMamPreferenceAlways = false;
-    private final AtomicInteger mPendingServiceDiscoveries = new AtomicInteger(0);
     private final AtomicBoolean mWaitForDisco = new AtomicBoolean(true);
     private final AtomicBoolean mWaitingForSmCatchup = new AtomicBoolean(false);
     private final AtomicInteger mSmCatchupMessageCounter = new AtomicInteger(0);
@@ -200,6 +209,7 @@ public class XmppConnection implements Runnable {
     private Resolver.Result seeOtherHostResolverResult;
     private volatile Thread mThread;
     private CountDownLatch mStreamCountDownLatch;
+    private final ClassToInstanceMap<AbstractManager> managers;
 
     public XmppConnection(final Account account, final XmppConnectionService service) {
         this.account = account;
@@ -209,6 +219,12 @@ public class XmppConnection implements Runnable {
         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.getApplicationContext(), this))
+                        .build();
     }
 
     private static void fixResource(final Context context, final Account account) {
@@ -2000,9 +2016,7 @@ public class XmppConnection implements Runnable {
             this.mStanzaQueue.clear();
         }
         this.redirectionUrl = null;
-        synchronized (this.disco) {
-            disco.clear();
-        }
+        getManager(DiscoManager.class).clear();
         synchronized (this.commands) {
             this.commands.clear();
         }
@@ -2165,41 +2179,99 @@ public class XmppConnection implements Runnable {
             final boolean waitForDisco, final boolean carbonsEnabled) {
         features.carbonsEnabled = carbonsEnabled;
         features.blockListRequested = false;
-        synchronized (this.disco) {
-            this.disco.clear();
-        }
+        getManager(DiscoManager.class).clear();
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": starting service discovery");
-        mPendingServiceDiscoveries.set(0);
         mWaitForDisco.set(waitForDisco);
         this.lastDiscoStarted = SystemClock.elapsedRealtime();
         mXmppConnectionService.scheduleWakeUpCall(
                 Config.CONNECT_DISCO_TIMEOUT * 1000L, account.getUuid().hashCode());
-        final Element caps = streamFeatures.findChild("c");
-        final String hash = caps == null ? null : caps.getAttribute("hash");
-        final String ver = caps == null ? null : caps.getAttribute("ver");
-        ServiceDiscoveryResult discoveryResult = null;
-        if (hash != null && ver != null) {
-            discoveryResult =
-                    mXmppConnectionService.getCachedServiceDiscoveryResult(new Pair<>(hash, ver));
-        }
-        final boolean requestDiscoItemsFirst =
-                !account.isOptionSet(Account.OPTION_LOGGED_IN_SUCCESSFULLY);
-        if (requestDiscoItemsFirst) {
-            sendServiceDiscoveryItems(account.getDomain());
-        }
-        if (discoveryResult == null) {
-            sendServiceDiscoveryInfo(account.getDomain());
-        } else {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server caps came from cache");
-            disco.put(account.getDomain(), discoveryResult);
-        }
+
+        final var nodeHash = streamFeatures.getCapabilities();
+        final var serverInfoFuture =
+                getManager(DiscoManager.class)
+                        .infoOrCache(Entity.discoItem(account.getDomain()), nodeHash);
+
         final var features = getFeatures();
         if (!features.bind2()) {
             discoverMamPreferences();
         }
-        sendServiceDiscoveryInfo(account.getJid().asBareJid());
-        if (!requestDiscoItemsFirst) {
-            sendServiceDiscoveryItems(account.getDomain());
+
+        final var accountInfoFuture =
+                getManager(DiscoManager.class)
+                        .info(Entity.discoItem(account.getJid().asBareJid()), null);
+
+        final var itemsFuture =
+                getManager(DiscoManager.class).itemsWithInfo(Entity.discoItem(account.getDomain()));
+
+        final var catchingServerFuture =
+                Futures.catching(
+                        serverInfoFuture,
+                        DiscoManager.CapsHashMismatchException.class,
+                        input -> {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid() + ": error in server caps",
+                                    input);
+                            return null;
+                        },
+                        MoreExecutors.directExecutor());
+
+        Futures.addCallback(
+                Futures.allAsList(accountInfoFuture, catchingServerFuture),
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(List<Object> result) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid() + ": advanced stream future done");
+                        enableAdvancedStreamFeatures();
+                    }
+
+                    @Override
+                    public void onFailure(@Nullable Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "could not fetch disco for advanced stream features",
+                                throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+
+        if (mWaitForDisco.get()) {
+            final ListenableFuture<Void> discoComplete =
+                    Futures.whenAllComplete(serverInfoFuture, accountInfoFuture, itemsFuture)
+                            .call(() -> null, MoreExecutors.directExecutor());
+            Futures.addCallback(
+                    discoComplete,
+                    new FutureCallback<>() {
+                        @Override
+                        public void onSuccess(Void result) {
+                            if (timeout(serverInfoFuture, accountInfoFuture, itemsFuture)) {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": reached timeout while waiting for disco");
+                                return;
+                            }
+                            if (mWaitForDisco.compareAndSet(true, false)) {
+                                finalizeBindOrError();
+                            } else {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": disco complete but bind was already"
+                                                + " finalized");
+                            }
+                        }
+
+                        @Override
+                        public void onFailure(@NonNull Throwable t) {
+                            Log.d(Config.LOGTAG, "error in disco: ", t);
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        } else {
+            finalizeBind();
         }
 
         if (!mWaitForDisco.get()) {
@@ -2208,63 +2280,19 @@ public class XmppConnection implements Runnable {
         this.lastSessionStarted = SystemClock.elapsedRealtime();
     }
 
-    private void sendServiceDiscoveryInfo(final Jid jid) {
-        mPendingServiceDiscoveries.incrementAndGet();
-        final Iq iq = new Iq(Iq.Type.GET);
-        iq.setTo(jid);
-        iq.query("http://jabber.org/protocol/disco#info");
-        this.sendIqPacket(
-                iq,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.RESULT) {
-                        boolean advancedStreamFeaturesLoaded;
-                        synchronized (XmppConnection.this.disco) {
-                            ServiceDiscoveryResult result = new ServiceDiscoveryResult(packet);
-                            if (jid.equals(account.getDomain())) {
-                                mXmppConnectionService.databaseBackend.insertDiscoveryResult(
-                                        result);
-                            }
-                            disco.put(jid, result);
-                            advancedStreamFeaturesLoaded =
-                                    disco.containsKey(account.getDomain())
-                                            && disco.containsKey(account.getJid().asBareJid());
-                        }
-                        if (advancedStreamFeaturesLoaded
-                                && (jid.equals(account.getDomain())
-                                        || jid.equals(account.getJid().asBareJid()))) {
-                            enableAdvancedStreamFeatures();
-                        }
-                    } else if (packet.getType() == Iq.Type.ERROR) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": could not query disco info for "
-                                        + jid.toString());
-                        final boolean serverOrAccount =
-                                jid.equals(account.getDomain())
-                                        || jid.equals(account.getJid().asBareJid());
-                        final boolean advancedStreamFeaturesLoaded;
-                        if (serverOrAccount) {
-                            synchronized (XmppConnection.this.disco) {
-                                disco.put(jid, ServiceDiscoveryResult.empty());
-                                advancedStreamFeaturesLoaded =
-                                        disco.containsKey(account.getDomain())
-                                                && disco.containsKey(account.getJid().asBareJid());
-                            }
-                        } else {
-                            advancedStreamFeaturesLoaded = false;
-                        }
-                        if (advancedStreamFeaturesLoaded) {
-                            enableAdvancedStreamFeatures();
-                        }
-                    }
-                    if (packet.getType() != Iq.Type.TIMEOUT) {
-                        if (mPendingServiceDiscoveries.decrementAndGet() == 0
-                                && mWaitForDisco.compareAndSet(true, false)) {
-                            finalizeBind();
-                        }
+    private boolean timeout(final ListenableFuture<?>... futures) {
+        for (final ListenableFuture<?> future : futures) {
+            if (future.isDone()) {
+                try {
+                    future.get();
+                } catch (final Exception e) {
+                    if (Throwables.getRootCause(e) instanceof TimeoutException) {
+                        return true;
                     }
-                });
+                }
+            }
+        }
+        return false;
     }
 
     private void discoverMamPreferences() {
@@ -2288,39 +2316,42 @@ public class XmppConnection implements Runnable {
     }
 
     private void discoverCommands() {
-        final Iq request = new Iq(Iq.Type.GET);
-        request.setTo(account.getDomain());
-        request.addChild("query", Namespace.DISCO_ITEMS).setAttribute("node", Namespace.COMMANDS);
-        sendIqPacket(
-                request,
-                (response) -> {
-                    if (response.getType() == Iq.Type.RESULT) {
-                        final Element query = response.findChild("query", Namespace.DISCO_ITEMS);
-                        if (query == null) {
-                            return;
-                        }
-                        final HashMap<String, Jid> commands = new HashMap<>();
-                        for (final Element child : query.getChildren()) {
-                            if ("item".equals(child.getName())) {
-                                final String node = child.getAttribute("node");
-                                final Jid jid = child.getAttributeAsJid("jid");
-                                if (node != null && jid != null) {
-                                    commands.put(node, jid);
-                                }
-                            }
-                        }
-                        synchronized (this.commands) {
-                            this.commands.clear();
-                            this.commands.putAll(commands);
+        final var future =
+                getManager(DiscoManager.class).commands(Entity.discoItem(account.getDomain()));
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(Map<String, Jid> result) {
+                        synchronized (XmppConnection.this.commands) {
+                            XmppConnection.this.commands.clear();
+                            XmppConnection.this.commands.putAll(result);
                         }
                     }
-                });
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid() + ": could not fetch commands",
+                                throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
     public boolean isMamPreferenceAlways() {
         return isMamPreferenceAlways;
     }
 
+    private void finalizeBindOrError() {
+        try {
+            finalizeBind();
+        } catch (final Exception e) {
+            throw new Error(e);
+        }
+    }
+
     private void finalizeBind() {
         this.offlineMessagesRetrieved = false;
         this.bindListener.run();
@@ -2344,46 +2375,6 @@ public class XmppConnection implements Runnable {
         }
     }
 
-    private void sendServiceDiscoveryItems(final Jid server) {
-        mPendingServiceDiscoveries.incrementAndGet();
-        final Iq iq = new Iq(Iq.Type.GET);
-        iq.setTo(server.getDomain());
-        iq.query("http://jabber.org/protocol/disco#items");
-        this.sendIqPacket(
-                iq,
-                (packet) -> {
-                    if (packet.getType() == Iq.Type.RESULT) {
-                        final HashSet<Jid> items = new HashSet<>();
-                        final List<Element> elements = packet.query().getChildren();
-                        for (final Element element : elements) {
-                            if (element.getName().equals("item")) {
-                                final Jid jid =
-                                        Jid.Invalid.getNullForInvalid(
-                                                element.getAttributeAsJid("jid"));
-                                if (jid != null && !jid.equals(account.getDomain())) {
-                                    items.add(jid);
-                                }
-                            }
-                        }
-                        for (Jid jid : items) {
-                            sendServiceDiscoveryInfo(jid);
-                        }
-                    } else {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": could not query disco items of "
-                                        + server);
-                    }
-                    if (packet.getType() != Iq.Type.TIMEOUT) {
-                        if (mPendingServiceDiscoveries.decrementAndGet() == 0
-                                && mWaitForDisco.compareAndSet(true, false)) {
-                            finalizeBind();
-                        }
-                    }
-                });
-    }
-
     private void sendEnableCarbons() {
         final Iq iq = new Iq(Iq.Type.SET);
         iq.addChild("enable", Namespace.CARBONS);
@@ -2726,28 +2717,23 @@ public class XmppConnection implements Runnable {
         this.boundStreamFeatures = null;
     }
 
-    private List<Entry<Jid, ServiceDiscoveryResult>> findDiscoItemsByFeature(final String feature) {
-        synchronized (this.disco) {
-            final List<Entry<Jid, ServiceDiscoveryResult>> items = new ArrayList<>();
-            for (final Entry<Jid, ServiceDiscoveryResult> cursor : this.disco.entrySet()) {
-                if (cursor.getValue().getFeatures().contains(feature)) {
-                    items.add(cursor);
-                }
-            }
-            return items;
-        }
+    public <M extends AbstractManager> M getManager(final Class<M> clazz) {
+        return this.managers.getInstance(clazz);
     }
 
-    public Entry<Jid, ServiceDiscoveryResult> getServiceDiscoveryResultByFeature(
-            final String feature) {
-        synchronized (this.disco) {
-            for (final var cursor : this.disco.entrySet()) {
-                if (cursor.getValue().getFeatures().contains(feature)) {
-                    return cursor;
-                }
+    private List<Entry<Jid, InfoQuery>> findDiscoItemsByFeature(final String feature) {
+        final List<Entry<Jid, InfoQuery>> items = new ArrayList<>();
+        for (final Entry<Jid, InfoQuery> cursor :
+                getManager(DiscoManager.class).getServerItems().entrySet()) {
+            if (cursor.getValue().getFeatureStrings().contains(feature)) {
+                items.add(cursor);
             }
-            return null;
         }
+        return items;
+    }
+
+    public Entry<Jid, InfoQuery> getServiceDiscoveryResultByFeature(final String feature) {
+        return Iterables.getFirst(findDiscoItemsByFeature(feature), null);
     }
 
     public Jid findDiscoItemByFeature(final String feature) {
@@ -2775,15 +2761,14 @@ public class XmppConnection implements Runnable {
 
     public List<String> getMucServers() {
         List<String> servers = new ArrayList<>();
-        synchronized (this.disco) {
-            for (final Entry<Jid, ServiceDiscoveryResult> cursor : disco.entrySet()) {
-                final ServiceDiscoveryResult value = cursor.getValue();
-                if (value.getFeatures().contains("http://jabber.org/protocol/muc")
-                        && value.hasIdentity("conference", "text")
-                        && !value.getFeatures().contains("jabber:iq:gateway")
-                        && !value.hasIdentity("conference", "irc")) {
-                    servers.add(cursor.getKey().toString());
-                }
+        for (final Entry<Jid, InfoQuery> entry :
+                getManager(DiscoManager.class).getServerItems().entrySet()) {
+            final var value = entry.getValue();
+            if (value.getFeatureStrings().contains("http://jabber.org/protocol/muc")
+                    && value.hasIdentityWithCategoryAndType("conference", "text")
+                    && !value.getFeatureStrings().contains("jabber:iq:gateway")
+                    && !value.hasIdentityWithCategoryAndType("conference", "irc")) {
+                servers.add(entry.getKey().toString());
             }
         }
         return servers;
@@ -2910,6 +2895,10 @@ public class XmppConnection implements Runnable {
         this.changeStatus(Account.State.CONNECTION_TIMEOUT);
     }
 
+    public Account getAccount() {
+        return this.account;
+    }
+
     private class MyKeyManager implements X509KeyManager {
         @Override
         public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) {
@@ -3033,6 +3022,29 @@ public class XmppConnection implements Runnable {
         }
     }
 
+    public abstract static class Delegate {
+
+        protected final Context context;
+        protected final XmppConnection connection;
+
+        protected Delegate(final Context context, final XmppConnection connection) {
+            this.context = context;
+            this.connection = connection;
+        }
+
+        protected Account getAccount() {
+            return connection.account;
+        }
+
+        protected DatabaseBackend getDatabase() {
+            return DatabaseBackend.getInstance(context);
+        }
+
+        protected <T extends AbstractManager> T getManager(final Class<T> type) {
+            return connection.getManager(type);
+        }
+    }
+
     public class Features {
         XmppConnection connection;
         private boolean carbonsEnabled = false;
@@ -3044,10 +3056,8 @@ public class XmppConnection implements Runnable {
         }
 
         private boolean hasDiscoFeature(final Jid server, final String feature) {
-            synchronized (XmppConnection.this.disco) {
-                final ServiceDiscoveryResult sdr = connection.disco.get(server);
-                return sdr != null && sdr.getFeatures().contains(feature);
-            }
+            final var infoQuery = getManager(DiscoManager.class).get(server);
+            return infoQuery != null && infoQuery.getFeatureStrings().contains(feature);
         }
 
         public boolean carbons() {
@@ -3103,19 +3113,16 @@ public class XmppConnection implements Runnable {
         }
 
         public boolean pep() {
-            synchronized (XmppConnection.this.disco) {
-                ServiceDiscoveryResult info = disco.get(account.getJid().asBareJid());
-                return info != null && info.hasIdentity("pubsub", "pep");
-            }
+            final var infoQuery = getManager(DiscoManager.class).get(account.getJid().asBareJid());
+            return infoQuery != null && infoQuery.hasIdentityWithCategoryAndType("pubsub", "pep");
         }
 
         public boolean pepPersistent() {
-            synchronized (XmppConnection.this.disco) {
-                ServiceDiscoveryResult info = disco.get(account.getJid().asBareJid());
-                return info != null
-                        && info.getFeatures()
-                                .contains("http://jabber.org/protocol/pubsub#persistent-items");
-            }
+            final var infoQuery = getManager(DiscoManager.class).get(account.getJid().asBareJid());
+            return infoQuery != null
+                    && infoQuery
+                            .getFeatureStrings()
+                            .contains("http://jabber.org/protocol/pubsub#persistent-items");
         }
 
         public boolean bind2() {
@@ -3150,9 +3157,9 @@ public class XmppConnection implements Runnable {
             return MessageArchiveService.Version.has(getAccountFeatures());
         }
 
-        public List<String> getAccountFeatures() {
-            ServiceDiscoveryResult result = connection.disco.get(account.getJid().asBareJid());
-            return result == null ? Collections.emptyList() : result.getFeatures();
+        public Collection<String> getAccountFeatures() {
+            final var infoQuery = getManager(DiscoManager.class).get(account.getJid().asBareJid());
+            return infoQuery == null ? Collections.emptyList() : infoQuery.getFeatureStrings();
         }
 
         public boolean push() {
@@ -3169,12 +3176,12 @@ public class XmppConnection implements Runnable {
         }
 
         public HttpUrl getServiceOutageStatus() {
-            final var disco = connection.disco.get(account.getDomain());
+            final var disco = getManager(DiscoManager.class).get(account.getDomain());
             if (disco == null) {
                 return null;
             }
             final var address =
-                    disco.getExtendedDiscoInformation(
+                    disco.getServiceDiscoveryExtension(
                             Namespace.SERVICE_OUTAGE_STATUS, "external-status-addresses");
             if (Strings.isNullOrEmpty(address)) {
                 return null;
@@ -3195,7 +3202,7 @@ public class XmppConnection implements Runnable {
                 maxSize =
                         Long.parseLong(
                                 result.getValue()
-                                        .getExtendedDiscoInformation(
+                                        .getServiceDiscoveryExtension(
                                                 Namespace.HTTP_UPLOAD, "max-file-size"));
             } catch (final Exception e) {
                 return true;
@@ -3224,7 +3231,7 @@ public class XmppConnection implements Runnable {
             try {
                 return Long.parseLong(
                         result.getValue()
-                                .getExtendedDiscoInformation(
+                                .getServiceDiscoveryExtension(
                                         Namespace.HTTP_UPLOAD, "max-file-size"));
             } catch (final Exception e) {
                 return -1;
  
  
  
    
    @@ -5,18 +5,16 @@ import androidx.annotation.NonNull;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.entities.Presence;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
 import im.conversations.android.xmpp.model.jingle.Jingle;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.util.Arrays;
@@ -331,14 +329,15 @@ public abstract class AbstractJingleConnection {
     }
 
     protected boolean remoteHasFeature(final String feature) {
-        final Contact contact = id.getContact();
-        final Presence presence =
-                contact.getPresences().get(Strings.nullToEmpty(id.with.getResource()));
-        final ServiceDiscoveryResult serviceDiscoveryResult =
-                presence == null ? null : presence.getServiceDiscoveryResult();
-        final List<String> features =
-                serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures();
-        return features != null && features.contains(feature);
+        final var connection = id.account.getXmppConnection();
+        if (connection == null) {
+            return false;
+        }
+        final var infoQuery = connection.getManager(DiscoManager.class).get(id.with);
+        if (infoQuery == null) {
+            return false;
+        }
+        return infoQuery.hasFeature(feature);
     }
 
     public static class Id {
  
  
  
    
    @@ -3,37 +3,34 @@ package eu.siacs.conversations.xmpp.jingle;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableSet;
-
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Presences;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 
-import eu.siacs.conversations.entities.Contact;
-import eu.siacs.conversations.entities.Presence;
-import eu.siacs.conversations.entities.Presences;
-import eu.siacs.conversations.entities.ServiceDiscoveryResult;
-import eu.siacs.conversations.xml.Namespace;
-
 public class RtpCapability {
 
-    private static final List<String> BASIC_RTP_REQUIREMENTS = Arrays.asList(
-            Namespace.JINGLE,
-            Namespace.JINGLE_TRANSPORT_ICE_UDP,
-            Namespace.JINGLE_APPS_RTP,
-            Namespace.JINGLE_APPS_DTLS
-    );
-    private static final Collection<String> VIDEO_REQUIREMENTS = Arrays.asList(
-            Namespace.JINGLE_FEATURE_AUDIO,
-            Namespace.JINGLE_FEATURE_VIDEO
-    );
+    private static final List<String> BASIC_RTP_REQUIREMENTS =
+            Arrays.asList(
+                    Namespace.JINGLE,
+                    Namespace.JINGLE_TRANSPORT_ICE_UDP,
+                    Namespace.JINGLE_APPS_RTP,
+                    Namespace.JINGLE_APPS_DTLS);
+    private static final Collection<String> VIDEO_REQUIREMENTS =
+            Arrays.asList(Namespace.JINGLE_FEATURE_AUDIO, Namespace.JINGLE_FEATURE_VIDEO);
 
-    public static Capability check(final Presence presence) {
-        final ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
-        final Set<String> features = disco == null ? Collections.emptySet() : ImmutableSet.copyOf(disco.getFeatures());
+    public static Capability check(final InfoQuery infoQuery) {
+        final Set<String> features =
+                infoQuery == null
+                        ? Collections.emptySet()
+                        : ImmutableSet.copyOf(infoQuery.getFeatureStrings());
         if (features.containsAll(BASIC_RTP_REQUIREMENTS)) {
             if (features.containsAll(VIDEO_REQUIREMENTS)) {
                 return Capability.VIDEO;
@@ -46,15 +43,23 @@ public class RtpCapability {
     }
 
     public static String[] filterPresences(final Contact contact, Capability required) {
+        final var connection = contact.getAccount().getXmppConnection();
+        if (connection == null) {
+            return new String[0];
+        }
         final Presences presences = contact.getPresences();
         final ArrayList<String> resources = new ArrayList<>();
-        for (final Map.Entry<String, Presence> presence : presences.getPresencesMap().entrySet()) {
-            final Capability capability = check(presence.getValue());
+        for (final String resource : presences.getPresencesMap().keySet()) {
+            final var jid =
+                    Strings.isNullOrEmpty(resource)
+                            ? contact.getJid().asBareJid()
+                            : contact.getJid().withResource(resource);
+            final Capability capability = check(connection.getManager(DiscoManager.class).get(jid));
             if (capability == Capability.NONE) {
                 continue;
             }
             if (required == Capability.AUDIO || capability == required) {
-                resources.add(presence.getKey());
+                resources.add(resource);
             }
         }
         return resources.toArray(new String[0]);
@@ -69,9 +74,17 @@ public class RtpCapability {
         if (presences.isEmpty() && allowFallback && contact.getAccount().isEnabled()) {
             return contact.getRtpCapability();
         }
+        final var connection = contact.getAccount().getXmppConnection();
+        if (connection == null) {
+            return Capability.NONE;
+        }
         Capability result = Capability.NONE;
-        for (final Presence presence : presences.getPresences()) {
-            Capability capability = check(presence);
+        for (final String resource : presences.getPresencesMap().keySet()) {
+            final var jid =
+                    Strings.isNullOrEmpty(resource)
+                            ? contact.getJid().asBareJid()
+                            : contact.getJid().withResource(resource);
+            final Capability capability = check(connection.getManager(DiscoManager.class).get(jid));
             if (capability == Capability.VIDEO) {
                 result = capability;
             } else if (capability == Capability.AUDIO && result == Capability.NONE) {
@@ -83,18 +96,43 @@ public class RtpCapability {
 
     // do all devices that support Rtp Call also support JMI?
     public static boolean jmiSupport(final Contact contact) {
+        final var connection = contact.getAccount().getXmppConnection();
+        if (connection == null) {
+            return false;
+        }
         return !Collections2.transform(
-                Collections2.filter(
-                        contact.getPresences().getPresences(),
-                        p -> RtpCapability.check(p) != RtpCapability.Capability.NONE),
-                p -> {
-                    ServiceDiscoveryResult disco = p.getServiceDiscoveryResult();
-                    return disco != null && disco.getFeatures().contains(Namespace.JINGLE_MESSAGE);
-                }).contains(false);
+                        Collections2.filter(
+                                contact.getPresences().getPresencesMap().keySet(),
+                                p ->
+                                        RtpCapability.check(
+                                                        connection
+                                                                .getManager(DiscoManager.class)
+                                                                .get(
+                                                                        Strings.isNullOrEmpty(p)
+                                                                                ? contact.getJid()
+                                                                                        .asBareJid()
+                                                                                : contact.getJid()
+                                                                                        .withResource(
+                                                                                                p)))
+                                                != Capability.NONE),
+                        p -> {
+                            final var disco =
+                                    connection
+                                            .getManager(DiscoManager.class)
+                                            .get(
+                                                    Strings.isNullOrEmpty(p)
+                                                            ? contact.getJid().asBareJid()
+                                                            : contact.getJid().withResource(p));
+                            return disco != null
+                                    && disco.getFeatureStrings().contains(Namespace.JINGLE_MESSAGE);
+                        })
+                .contains(false);
     }
 
     public enum Capability {
-        NONE, AUDIO, VIDEO;
+        NONE,
+        AUDIO,
+        VIDEO;
 
         public static Capability of(String value) {
             if (Strings.isNullOrEmpty(value)) {
@@ -107,5 +145,4 @@ public class RtpCapability {
             }
         }
     }
-
 }
  
  
  
    
    @@ -0,0 +1,11 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.xmpp.XmppConnection;
+
+public abstract class AbstractManager extends XmppConnection.Delegate {
+
+    protected AbstractManager(final Context context, final XmppConnection connection) {
+        super(context, connection);
+    }
+}
  
  
  
    
    @@ -0,0 +1,307 @@
+package eu.siacs.conversations.xmpp.manager;
+
+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.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.Config;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.Entity;
+import im.conversations.android.xmpp.EntityCapabilities;
+import im.conversations.android.xmpp.EntityCapabilities2;
+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.stanza.Iq;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+public class DiscoManager extends AbstractManager {
+
+    public static final String CAPABILITY_NODE = "http://conversations.im";
+
+    // this is the runtime cache that stores disco information for all entities seen during a
+    // session
+
+    // a caps cache will be build in the database
+
+    private final Map<Jid, InfoQuery> entityInformation = new HashMap<>();
+    private final Map<Jid, ImmutableSet<Jid>> discoItems = new HashMap<>();
+
+    public DiscoManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public static EntityCapabilities.Hash buildHashFromNode(final String node) {
+        final var capsPrefix = CAPABILITY_NODE + "#";
+        final var caps2Prefix = Namespace.ENTITY_CAPABILITIES_2 + "#";
+        if (node.startsWith(capsPrefix)) {
+            final String hash = node.substring(capsPrefix.length());
+            if (Strings.isNullOrEmpty(hash)) {
+                return null;
+            }
+            if (BaseEncoding.base64().canDecode(hash)) {
+                return EntityCapabilities.EntityCapsHash.of(hash);
+            }
+        } else if (node.startsWith(caps2Prefix)) {
+            final String caps = node.substring(caps2Prefix.length());
+            if (Strings.isNullOrEmpty(caps)) {
+                return null;
+            }
+            final int separator = caps.lastIndexOf('.');
+            if (separator < 0) {
+                return null;
+            }
+            final Hash.Algorithm algorithm = Hash.Algorithm.tryParse(caps.substring(0, separator));
+            final String hash = caps.substring(separator + 1);
+            if (algorithm == null || Strings.isNullOrEmpty(hash)) {
+                return null;
+            }
+            if (BaseEncoding.base64().canDecode(hash)) {
+                return EntityCapabilities2.EntityCaps2Hash.of(algorithm, hash);
+            }
+        }
+        return null;
+    }
+
+    public ListenableFuture<Void> infoOrCache(
+            final Entity entity,
+            final im.conversations.android.xmpp.model.capabilties.EntityCapabilities.NodeHash
+                    nodeHash) {
+        if (nodeHash == null) {
+            return infoOrCache(entity, null, null);
+        }
+        return infoOrCache(entity, nodeHash.node, nodeHash.hash);
+    }
+
+    public ListenableFuture<Void> infoOrCache(
+            final Entity entity, final String node, final EntityCapabilities.Hash hash) {
+        final var cached = getDatabase().getInfoQuery(hash);
+        if (cached != null) {
+            if (node == null || hash != null) {
+                this.put(entity.address, cached);
+            }
+            return Futures.immediateFuture(null);
+        }
+        return Futures.transform(
+                info(entity, node, hash), f -> null, MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<InfoQuery> info(
+            @NonNull final Entity entity, @Nullable final String node) {
+        return info(entity, node, null);
+    }
+
+    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 iqRequest = new Iq(Iq.Type.GET);
+        iqRequest.setTo(entity.address);
+        final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
+        if (requestNode != null) {
+            infoQueryRequest.setNode(requestNode);
+        }
+        final var future = connection.sendIqPacket(iqRequest);
+        return Futures.transform(
+                future,
+                iqResult -> {
+                    final var infoQuery = iqResult.getExtension(InfoQuery.class);
+                    if (infoQuery == null) {
+                        throw new IllegalStateException("Response did not have query child");
+                    }
+                    if (!Objects.equals(requestNode, infoQuery.getNode())) {
+                        throw new IllegalStateException(
+                                "Node in response did not match node in request");
+                    }
+
+                    if (node == null
+                            || (hash != null
+                                    && hash.capabilityNode(node).equals(infoQuery.getNode()))) {
+                        // only storing results w/o nodes
+                        this.put(entity.address, infoQuery);
+                    }
+
+                    final var caps = EntityCapabilities.hash(infoQuery);
+                    final var caps2 = EntityCapabilities2.hash(infoQuery);
+                    if (hash instanceof EntityCapabilities.EntityCapsHash) {
+                        checkMatch(
+                                (EntityCapabilities.EntityCapsHash) hash,
+                                caps,
+                                EntityCapabilities.EntityCapsHash.class);
+                    }
+                    if (hash instanceof EntityCapabilities2.EntityCaps2Hash) {
+                        checkMatch(
+                                (EntityCapabilities2.EntityCaps2Hash) hash,
+                                caps2,
+                                EntityCapabilities2.EntityCaps2Hash.class);
+                    }
+                    // we want to avoid caching disco info for entities that put variable data (like
+                    // number of occupants in a MUC) into it
+                    final boolean cache =
+                            Objects.nonNull(hash)
+                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES)
+                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES_2);
+
+                    if (cache) {
+                        getDatabase().insertCapsCache(caps, caps2, infoQuery);
+                    }
+
+                    return infoQuery;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private <H extends EntityCapabilities.Hash> void checkMatch(
+            final H expected, final H was, final Class<H> clazz) {
+        if (Arrays.equals(expected.hash, was.hash)) {
+            return;
+        }
+        throw new CapsHashMismatchException(
+                String.format(
+                        "%s mismatch. Expected %s was %s",
+                        clazz.getSimpleName(),
+                        BaseEncoding.base64().encode(expected.hash),
+                        BaseEncoding.base64().encode(was.hash)));
+    }
+
+    public ListenableFuture<Collection<Item>> items(final Entity.DiscoItem entity) {
+        return items(entity, null);
+    }
+
+    public ListenableFuture<Collection<Item>> items(
+            final Entity.DiscoItem entity, @Nullable final String node) {
+        final var requestNode = Strings.emptyToNull(node);
+        final var iqPacket = new Iq(Iq.Type.GET);
+        iqPacket.setTo(entity.address);
+        final ItemsQuery itemsQueryRequest = iqPacket.addExtension(new ItemsQuery());
+        if (requestNode != null) {
+            itemsQueryRequest.setNode(requestNode);
+        }
+        final var future = connection.sendIqPacket(iqPacket);
+        return Futures.transform(
+                future,
+                iqResult -> {
+                    final var itemsQuery = iqResult.getExtension(ItemsQuery.class);
+                    if (itemsQuery == null) {
+                        throw new IllegalStateException();
+                    }
+                    if (!Objects.equals(requestNode, itemsQuery.getNode())) {
+                        throw new IllegalStateException(
+                                "Node in response did not match node in request");
+                    }
+                    final var items = itemsQuery.getExtensions(Item.class);
+
+                    final var validItems =
+                            Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
+
+                    final var itemsAsAddresses =
+                            ImmutableSet.copyOf(Collections2.transform(validItems, Item::getJid));
+                    if (node == null) {
+                        this.discoItems.put(entity.address, itemsAsAddresses);
+                    }
+                    return validItems;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<List<InfoQuery>> itemsWithInfo(final Entity.DiscoItem entity) {
+        final var itemsFutures = items(entity);
+        final var filtered =
+                Futures.transform(
+                        itemsFutures,
+                        items ->
+                                Collections2.filter(
+                                        items,
+                                        i ->
+                                                i.getNode() == null
+                                                        && !entity.address.equals(i.getJid())),
+                        MoreExecutors.directExecutor());
+        return Futures.transformAsync(
+                filtered,
+                items -> {
+                    Collection<ListenableFuture<InfoQuery>> infoFutures =
+                            Collections2.transform(
+                                    items, i -> info(Entity.discoItem(i.getJid()), i.getNode()));
+                    return Futures.allAsList(infoFutures);
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public ListenableFuture<Map<String, Jid>> commands(final Entity.DiscoItem entity) {
+        final var itemsFuture = items(entity, Namespace.COMMANDS);
+        return Futures.transform(
+                itemsFuture,
+                items -> {
+                    final var builder = new ImmutableMap.Builder<String, Jid>();
+                    for (final var item : items) {
+                        final var jid = item.getJid();
+                        final var node = item.getNode();
+                        if (Jid.Invalid.isValid(jid) && node != null) {
+                            builder.put(node, jid);
+                        }
+                    }
+                    return builder.buildKeepingLast();
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    public Map<Jid, InfoQuery> getServerItems() {
+        final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
+        final var domain = connection.getAccount().getDomain();
+        final var domainInfoQuery = get(domain);
+        if (domainInfoQuery != null) {
+            builder.put(domain, domainInfoQuery);
+        }
+        final var items = this.discoItems.get(domain);
+        if (items == null) {
+            return builder.build();
+        }
+        for (final var item : items) {
+            final var infoQuery = get(item);
+            if (infoQuery == null) {
+                continue;
+            }
+            builder.put(item, infoQuery);
+        }
+        return builder.buildKeepingLast();
+    }
+
+    private void put(final Jid address, final InfoQuery infoQuery) {
+        synchronized (this.entityInformation) {
+            this.entityInformation.put(address, infoQuery);
+        }
+    }
+
+    public InfoQuery get(final Jid address) {
+        synchronized (this.entityInformation) {
+            return this.entityInformation.get(address);
+        }
+    }
+
+    public void clear() {
+        synchronized (this.entityInformation) {
+            this.entityInformation.clear();
+        }
+    }
+
+    public static final class CapsHashMismatchException extends IllegalStateException {
+        public CapsHashMismatchException(final String message) {
+            super(message);
+        }
+    }
+}
  
  
  
    
    @@ -0,0 +1,21 @@
+package im.conversations.android.xml;
+
+import com.google.common.io.ByteSource;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.XmlReader;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class XmlElementReader {
+
+    public static Element read(byte[] bytes) throws IOException {
+        return read(ByteSource.wrap(bytes).openStream());
+    }
+
+    public static Element read(final InputStream inputStream) throws IOException {
+        try (final XmlReader xmlReader = new XmlReader()) {
+            xmlReader.setInputStream(inputStream);
+            return xmlReader.readElement(xmlReader.readTag());
+        }
+    }
+}
  
  
  
    
    @@ -1,6 +1,6 @@
 package im.conversations.android.xmpp;
 
-import org.jxmpp.jid.Jid;
+import eu.siacs.conversations.xmpp.Jid;
 
 public abstract class Entity {
 
  
  
  
    
    @@ -8,7 +8,6 @@ import com.google.common.hash.HashFunction;
 import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Bytes;
-
 import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.xmpp.model.Hash;
 import im.conversations.android.xmpp.model.data.Data;
@@ -42,16 +41,12 @@ public class EntityCapabilities2 {
     }
 
     private static HashFunction toHashFunction(final Hash.Algorithm algorithm) {
-        switch (algorithm) {
-            case SHA_1:
-                return Hashing.sha1();
-            case SHA_256:
-                return Hashing.sha256();
-            case SHA_512:
-                return Hashing.sha512();
-            default:
-                throw new IllegalArgumentException("Unknown hash algorithm");
-        }
+        return switch (algorithm) {
+            case SHA_1 -> Hashing.sha1();
+            case SHA_256 -> Hashing.sha256();
+            case SHA_512 -> Hashing.sha512();
+            default -> throw new IllegalArgumentException("Unknown hash algorithm");
+        };
     }
 
     private static String asHex(final String message) {
  
  
  
    
    @@ -29,6 +29,10 @@ public class Data extends Extension {
                 this.getExtensions(Field.class), f -> !FORM_TYPE.equals(f.getFieldName()));
     }
 
+    public Field getFieldByName(final String name) {
+        return Iterables.find(getFields(), f -> name.equals(f.getFieldName()), null);
+    }
+
     private void addField(final String name, final Object value) {
         addField(name, value, null);
     }
  
  
  
    
    @@ -1,6 +1,8 @@
 package im.conversations.android.xmpp.model.data;
-import eu.siacs.conversations.xml.Element;
+
 import com.google.common.collect.Collections2;
+import com.google.common.collect.Iterables;
+import eu.siacs.conversations.xml.Element;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 import java.util.Collection;
@@ -19,6 +21,10 @@ public class Field extends Extension {
         return Collections2.transform(getExtensions(Value.class), Element::getContent);
     }
 
+    public String getValue() {
+        return Iterables.getFirst(getValues(), null);
+    }
+
     public void setFieldName(String name) {
         this.setAttribute("var", name);
     }
  
  
  
    
    @@ -1,9 +1,12 @@
 package im.conversations.android.xmpp.model.disco.info;
 
+import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.data.Data;
 import java.util.Collection;
+import java.util.Objects;
 
 @XmlElement(name = "query")
 public class InfoQuery extends Extension {
@@ -35,4 +38,39 @@ public class InfoQuery extends Extension {
     public boolean hasIdentityWithCategory(final String category) {
         return Iterables.any(getIdentities(), i -> category.equals(i.getCategory()));
     }
+
+    public boolean hasIdentityWithCategoryAndType(final String category, final String type) {
+        return Iterables.any(
+                getIdentities(), i -> category.equals(i.getCategory()) && type.equals(i.getType()));
+    }
+
+    public Collection<String> getFeatureStrings() {
+        return Collections2.filter(
+                Collections2.transform(getFeatures(), Feature::getVar), Objects::nonNull);
+    }
+
+    public Collection<Data> getServiceDiscoveryExtensions() {
+        return getExtensions(Data.class);
+    }
+
+    public Data getServiceDiscoveryExtension(final String formType) {
+        return Iterables.find(
+                getServiceDiscoveryExtensions(), e -> formType.equals(e.getFormType()), null);
+    }
+
+    public String getServiceDiscoveryExtension(final String formType, final String fieldName) {
+        final var extension =
+                Iterables.find(
+                        getServiceDiscoveryExtensions(),
+                        e -> formType.equals(e.getFormType()),
+                        null);
+        if (extension == null) {
+            return null;
+        }
+        final var field = extension.getFieldByName(fieldName);
+        if (field == null) {
+            return null;
+        }
+        return field.getValue();
+    }
 }
  
  
  
    
    @@ -1,7 +1,10 @@
 package im.conversations.android.xmpp.model.stanza;
 
+import com.google.common.base.Strings;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.capabilties.EntityCapabilities;
+import im.conversations.android.xmpp.model.jabber.Show;
+import im.conversations.android.xmpp.model.jabber.Status;
 
 @XmlElement
 public class Presence extends Stanza implements EntityCapabilities {
@@ -9,4 +12,63 @@ public class Presence extends Stanza implements EntityCapabilities {
     public Presence() {
         super(Presence.class);
     }
+
+    public Availability getAvailability() {
+        final var show = getExtension(Show.class);
+        if (show == null) {
+            return Availability.ONLINE;
+        }
+        return Availability.valueOfShown(show.getContent());
+    }
+
+    public void setAvailability(final Availability availability) {
+        if (availability == null || availability == Availability.ONLINE) {
+            return;
+        }
+        this.addExtension(new Show()).setContent(availability.toShowString());
+    }
+
+    public void setStatus(final String status) {
+        if (Strings.isNullOrEmpty(status)) {
+            return;
+        }
+        this.addExtension(new Status()).setContent(status);
+    }
+
+    public String getStatus() {
+        final var status = getExtension(Status.class);
+        return status == null ? null : status.getContent();
+    }
+
+    public enum Availability {
+        CHAT,
+        ONLINE,
+        AWAY,
+        XA,
+        DND,
+        OFFLINE;
+
+        public String toShowString() {
+            return switch (this) {
+                case CHAT -> "chat";
+                case AWAY -> "away";
+                case XA -> "xa";
+                case DND -> "dnd";
+                default -> null;
+            };
+        }
+
+        public static Availability valueOfShown(final String content) {
+            if (Strings.isNullOrEmpty(content)) {
+                return Availability.ONLINE;
+            }
+            return switch (content) {
+                case "away" -> Availability.AWAY;
+                case "xa" -> Availability.XA;
+                case "dnd" -> Availability.DND;
+                case "chat" -> Availability.CHAT;
+                default -> Availability.ONLINE;
+            };
+        }
+    }
 }
  
  
  
    
    @@ -0,0 +1,339 @@
+package im.conversations.android.xmpp;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertNull;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import im.conversations.android.xml.XmlElementReader;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import im.conversations.android.xmpp.model.stanza.Iq;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.ConscryptMode;
+
+@RunWith(RobolectricTestRunner.class)
+@ConscryptMode(ConscryptMode.Mode.OFF)
+public class EntityCapabilitiesTest {
+
+    @Test
+    public void entityCaps() throws IOException {
+        final String xml =
+                """
+                <query xmlns='http://jabber.org/protocol/disco#info'
+                         node='http://code.google.com/p/exodus#QgayPKawpkPSDYmwT/WM94uAlu0='>
+                    <identity category='client' name='Exodus 0.9.1' type='pc'/>
+                    <feature var='http://jabber.org/protocol/caps'/>
+                    <feature var='http://jabber.org/protocol/disco#items'/>
+                    <feature var='http://jabber.org/protocol/disco#info'/>
+                    <feature var='http://jabber.org/protocol/muc'/>
+                  </query>""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(InfoQuery.class));
+        final InfoQuery info = (InfoQuery) element;
+        final String var = EntityCapabilities.hash(info).encoded();
+        Assert.assertEquals("QgayPKawpkPSDYmwT/WM94uAlu0=", var);
+    }
+
+    @Test
+    public void entityCapsComplexExample() throws IOException {
+        final String xml =
+                """
+                <query xmlns='http://jabber.org/protocol/disco#info'
+                         node='http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w='>
+                    <identity xml:lang='en' category='client' name='Psi 0.11' type='pc'/>
+                    <identity xml:lang='el' category='client' name='Ξ¨ 0.11' type='pc'/>
+                    <feature var='http://jabber.org/protocol/caps'/>
+                    <feature var='http://jabber.org/protocol/disco#info'/>
+                    <feature var='http://jabber.org/protocol/disco#items'/>
+                    <feature var='http://jabber.org/protocol/muc'/>
+                    <x xmlns='jabber:x:data' type='result'>
+                      <field var='FORM_TYPE' type='hidden'>
+                        <value>urn:xmpp:dataforms:softwareinfo</value>
+                      </field>
+                      <field var='ip_version' type='text-multi' >
+                        <value>ipv4</value>
+                        <value>ipv6</value>
+                      </field>
+                      <field var='os'>
+                        <value>Mac</value>
+                      </field>
+                      <field var='os_version'>
+                        <value>10.5.1</value>
+                      </field>
+                      <field var='software'>
+                        <value>Psi</value>
+                      </field>
+                      <field var='software_version'>
+                        <value>0.11</value>
+                      </field>
+                    </x>
+                  </query>""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(InfoQuery.class));
+        final InfoQuery info = (InfoQuery) element;
+        final String var = EntityCapabilities.hash(info).encoded();
+        Assert.assertEquals("q07IKJEyjvHSyhy//CH0CxmKi8w=", var);
+    }
+
+    @Test
+    public void entityCapsOpenFire() throws IOException {
+        final String xml =
+                """
+  <iq type="result" xmlns="jabber:client" to="inputmice3@igniterealtime.org/Conversations.cI4W" from="igniterealtime.org" id="L3xl8X8_kzvx">
+  <query node="https://www.igniterealtime.org/projects/openfire/#Cd91QBSG4JGOCEvRsSz64xeJPMk=" xmlns="http://jabber.org/protocol/disco#info">
+    <identity name="Openfire Server" type="im" category="server" xmlns="http://jabber.org/protocol/disco#info"/>
+    <identity type="pep" category="pubsub" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:raa:0#embed-presence-directed" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/caps" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-default" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#purge-nodes" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#subscription-options" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:raa:0#embed-message " xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#outcast-affiliation" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="msgoffline" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#delete-nodes" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:register" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#config-node" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#auto-create" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/disco#items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#delete-items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:mam:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:mam:1" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:mam:2" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:fulltext:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#persistent-items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#create-and-configure" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-affiliations" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:time" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#manage-subscriptions" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:bookmarks-conversion:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#auto-subscribe" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/offline" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#publish-options" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:carbons:2" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/address" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#collections" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#retrieve-subscriptions" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="vcard-temp" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#subscribe" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#create-nodes" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#get-pending" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:blocking" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#multi-subscribe" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#presence-notifications" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:ping" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:archive:manage" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#filtered-notifications" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:push:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#meta-data" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#multi-items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#item-ids" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:roster" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#instant-nodes" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#modify-affiliations" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:raa:0#embed-presence-sub" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#publisher-affiliation" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#access-open" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:version" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#retract-items" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:extdisco:1" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:privacy" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:extdisco:2" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/commands" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:last" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:raa:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/pubsub#publish" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:serverinfo:0" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="urn:xmpp:archive:auto" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/disco#info" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="jabber:iq:private" xmlns="http://jabber.org/protocol/disco#info"/>
+    <feature var="http://jabber.org/protocol/rsm" xmlns="http://jabber.org/protocol/disco#info"/>
+    <x type="result" xmlns="jabber:x:data">
+      <field var="FORM_TYPE" type="hidden" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">urn:xmpp:dataforms:softwareinfo</value>
+      </field>
+      <field var="os" type="text-single" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">Linux</value>
+      </field>
+      <field var="os_version" type="text-single" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">4.14.355-276.618.amzn2.x86_64 amd64 - Java 17.0.14</value>
+      </field>
+      <field var="software" type="text-single" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">Openfire</value>
+      </field>
+      <field var="software_version" type="text-single" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">5.0.0 Alpha</value>
+      </field>
+    </x>
+    <x type="result" xmlns="jabber:x:data">
+      <field var="FORM_TYPE" type="hidden" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">http://jabber.org/network/serverinfo</value>
+      </field>
+      <field var="admin-addresses" type="list-multi" xmlns="jabber:x:data">
+        <value xmlns="jabber:x:data">xmpp:dwd@dave.cridland.net</value>
+        <value xmlns="jabber:x:data">xmpp:akrherz@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">xmpp:benjamin@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">mailto:benjamin@holyarmy.org</value>
+        <value xmlns="jabber:x:data">xmpp:csh@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">xmpp:dan.caseley@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">mailto:dan.caseley@surevine.com</value>
+        <value xmlns="jabber:x:data">xmpp:flow@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">xmpp:gdt@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">mailto:greg.d.thomas@gmail.com</value>
+        <value xmlns="jabber:x:data">xmpp:guus.der.kinderen@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">mailto:guus.der.kinderen@gmail.com</value>
+        <value xmlns="jabber:x:data">xmpp:lg@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">xmpp:rcollier@igniterealtime.org</value>
+        <value xmlns="jabber:x:data">mailto:robincollier@hotmail.com</value>
+      </field>
+    </x>
+  </query>
+</iq>
+""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(Iq.class));
+        final var iq = (Iq) element;
+        final InfoQuery info = iq.getExtension(InfoQuery.class);
+        final String var = EntityCapabilities.hash(info).encoded();
+        Assert.assertEquals("Cd91QBSG4JGOCEvRsSz64xeJPMk=", var);
+    }
+
+    @Test
+    public void caps2() throws IOException {
+        final String xml =
+                """
+                <query xmlns="http://jabber.org/protocol/disco#info">
+                  <identity category="client" name="BombusMod" type="mobile"/>
+                  <feature var="http://jabber.org/protocol/si"/>
+                  <feature var="http://jabber.org/protocol/bytestreams"/>
+                  <feature var="http://jabber.org/protocol/chatstates"/>
+                  <feature var="http://jabber.org/protocol/disco#info"/>
+                  <feature var="http://jabber.org/protocol/disco#items"/>
+                  <feature var="urn:xmpp:ping"/>
+                  <feature var="jabber:iq:time"/>
+                  <feature var="jabber:iq:privacy"/>
+                  <feature var="jabber:iq:version"/>
+                  <feature var="http://jabber.org/protocol/rosterx"/>
+                  <feature var="urn:xmpp:time"/>
+                  <feature var="jabber:x:oob"/>
+                  <feature var="http://jabber.org/protocol/ibb"/>
+                  <feature var="http://jabber.org/protocol/si/profile/file-transfer"/>
+                  <feature var="urn:xmpp:receipts"/>
+                  <feature var="jabber:iq:roster"/>
+                  <feature var="jabber:iq:last"/>
+                </query>""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(InfoQuery.class));
+        final InfoQuery info = (InfoQuery) element;
+        final String var = EntityCapabilities2.hash(info).encoded();
+        Assert.assertEquals("kzBZbkqJ3ADrj7v08reD1qcWUwNGHaidNUgD7nHpiw8=", var);
+    }
+
+    @Test
+    public void caps2complex() throws IOException {
+        final String xml =
+                """
+                <query xmlns="http://jabber.org/protocol/disco#info">
+                  <identity category="client" name="Tkabber" type="pc" xml:lang="en"/>
+                  <identity category="client" name="Π’ΠΊΠ°Π±Π±Π΅Ρ" type="pc" xml:lang="ru"/>
+                  <feature var="games:board"/>
+                  <feature var="http://jabber.org/protocol/activity"/>
+                  <feature var="http://jabber.org/protocol/activity+notify"/>
+                  <feature var="http://jabber.org/protocol/bytestreams"/>
+                  <feature var="http://jabber.org/protocol/chatstates"/>
+                  <feature var="http://jabber.org/protocol/commands"/>
+                  <feature var="http://jabber.org/protocol/disco#info"/>
+                  <feature var="http://jabber.org/protocol/disco#items"/>
+                  <feature var="http://jabber.org/protocol/evil"/>
+                  <feature var="http://jabber.org/protocol/feature-neg"/>
+                  <feature var="http://jabber.org/protocol/geoloc"/>
+                  <feature var="http://jabber.org/protocol/geoloc+notify"/>
+                  <feature var="http://jabber.org/protocol/ibb"/>
+                  <feature var="http://jabber.org/protocol/iqibb"/>
+                  <feature var="http://jabber.org/protocol/mood"/>
+                  <feature var="http://jabber.org/protocol/mood+notify"/>
+                  <feature var="http://jabber.org/protocol/rosterx"/>
+                  <feature var="http://jabber.org/protocol/si"/>
+                  <feature var="http://jabber.org/protocol/si/profile/file-transfer"/>
+                  <feature var="http://jabber.org/protocol/tune"/>
+                  <feature var="http://www.facebook.com/xmpp/messages"/>
+                  <feature var="http://www.xmpp.org/extensions/xep-0084.html#ns-metadata+notify"/>
+                  <feature var="jabber:iq:avatar"/>
+                  <feature var="jabber:iq:browse"/>
+                  <feature var="jabber:iq:dtcp"/>
+                  <feature var="jabber:iq:filexfer"/>
+                  <feature var="jabber:iq:ibb"/>
+                  <feature var="jabber:iq:inband"/>
+                  <feature var="jabber:iq:jidlink"/>
+                  <feature var="jabber:iq:last"/>
+                  <feature var="jabber:iq:oob"/>
+                  <feature var="jabber:iq:privacy"/>
+                  <feature var="jabber:iq:roster"/>
+                  <feature var="jabber:iq:time"/>
+                  <feature var="jabber:iq:version"/>
+                  <feature var="jabber:x:data"/>
+                  <feature var="jabber:x:event"/>
+                  <feature var="jabber:x:oob"/>
+                  <feature var="urn:xmpp:avatar:metadata+notify"/>
+                  <feature var="urn:xmpp:ping"/>
+                  <feature var="urn:xmpp:receipts"/>
+                  <feature var="urn:xmpp:time"/>
+                  <x xmlns="jabber:x:data" type="result">
+                    <field type="hidden" var="FORM_TYPE">
+                      <value>urn:xmpp:dataforms:softwareinfo</value>
+                    </field>
+                    <field var="software">
+                      <value>Tkabber</value>
+                    </field>
+                    <field var="software_version">
+                      <value>0.11.1-svn-20111216-mod (Tcl/Tk 8.6b2)</value>
+                    </field>
+                    <field var="os">
+                      <value>Windows</value>
+                    </field>
+                    <field var="os_version">
+                      <value>XP</value>
+                    </field>
+                  </x>
+                </query>""";
+        final Element element = XmlElementReader.read(xml.getBytes(StandardCharsets.UTF_8));
+        assertThat(element, instanceOf(InfoQuery.class));
+        final InfoQuery info = (InfoQuery) element;
+        final String var = EntityCapabilities2.hash(info).encoded();
+        Assert.assertEquals("u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=", var);
+    }
+
+    @Test
+    public void parseCaps2Node() {
+        final var caps =
+                DiscoManager.buildHashFromNode(
+                        "urn:xmpp:caps#sha-256.u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=");
+        assertThat(caps, instanceOf(EntityCapabilities2.EntityCaps2Hash.class));
+    }
+
+    @Test
+    public void parseCaps2NodeMissingHash() {
+        final var caps = DiscoManager.buildHashFromNode("urn:xmpp:caps#sha-256.");
+        assertNull(caps);
+    }
+
+    @Test
+    public void parseCaps2NodeInvalid() {
+        final var caps = DiscoManager.buildHashFromNode("urn:xmpp:caps#-");
+        assertNull(caps);
+    }
+
+    @Test
+    public void parseCaps2NodeUnknownAlgo() {
+        final var caps = DiscoManager.buildHashFromNode("urn:xmpp:caps#test.test");
+        assertNull(caps);
+    }
+}