Detailed changes
  
  
    
    @@ -399,6 +399,13 @@
             <xmpp:version>0.4.0</xmpp:version>
         </xmpp:SupportedXep>
     </implements>
+    <implements>
+        <xmpp:SupportedXep>
+            <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0390.html"/>
+            <xmpp:status>complete</xmpp:status>
+            <xmpp:version>0.3.2</xmpp:version>
+        </xmpp:SupportedXep>
+    </implements>
     <implements>
         <xmpp:SupportedXep>
             <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0391.html"/>
  
  
  
    
    @@ -130,6 +130,18 @@ public class AppSettings {
         return getBooleanPreference(ALIGN_START, R.bool.align_start);
     }
 
+    public boolean isConfirmMessages() {
+        return getBooleanPreference(CONFIRM_MESSAGES, R.bool.confirm_messages);
+    }
+
+    public boolean isAllowMessageCorrection() {
+        return getBooleanPreference(ALLOW_MESSAGE_CORRECTION, R.bool.allow_message_correction);
+    }
+
+    public boolean isBroadcastLastActivity() {
+        return getBooleanPreference(BROADCAST_LAST_ACTIVITY, R.bool.last_activity);
+    }
+
     public boolean isUseTor() {
         return QuickConversationsService.isConversations()
                 && getBooleanPreference(USE_TOR, R.bool.use_tor);
  
  
  
    
    @@ -114,6 +114,8 @@ public final class Config {
     public static final boolean USE_DIRECT_JINGLE_CANDIDATES = true;
     public static final boolean USE_JINGLE_MESSAGE_INIT = true;
 
+    public static final boolean ENABLE_CAPS_CACHE = true;
+
     public static final boolean DISABLE_HTTP_UPLOAD = false;
     public static final boolean EXTENDED_SM_LOGGING = false; // log stanza counts
     public static final boolean BACKGROUND_STANZA_LOGGING =
  
  
  
    
    @@ -1,61 +1,15 @@
 package eu.siacs.conversations.generator;
 
-import android.util.Base64;
 import eu.siacs.conversations.BuildConfig;
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.XmppConnection;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
 import java.util.Locale;
 import java.util.TimeZone;
 
 public abstract class AbstractGenerator {
     private static final SimpleDateFormat DATE_FORMAT =
             new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
-    private final String[] STATIC_FEATURES = {
-        Namespace.JINGLE,
-        Namespace.JINGLE_APPS_FILE_TRANSFER,
-        Namespace.JINGLE_TRANSPORTS_S5B,
-        Namespace.JINGLE_TRANSPORTS_IBB,
-        Namespace.JINGLE_ENCRYPTED_TRANSPORT,
-        Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
-        "http://jabber.org/protocol/muc",
-        "jabber:x:conference",
-        Namespace.OOB,
-        "http://jabber.org/protocol/caps",
-        "http://jabber.org/protocol/disco#info",
-        "urn:xmpp:avatar:metadata+notify",
-        Namespace.NICK + "+notify",
-        "urn:xmpp:ping",
-        "jabber:iq:version",
-        "http://jabber.org/protocol/chatstates",
-        Namespace.REACTIONS
-    };
-    private final String[] MESSAGE_CONFIRMATION_FEATURES = {
-        "urn:xmpp:chat-markers:0", "urn:xmpp:receipts"
-    };
-    private final String[] MESSAGE_CORRECTION_FEATURES = {"urn:xmpp:message-correct:0"};
-    private final String[] PRIVACY_SENSITIVE = {
-        "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
-    };
-    private final String[] VOIP_NAMESPACES = {
-        Namespace.JINGLE_TRANSPORT_ICE_UDP,
-        Namespace.JINGLE_FEATURE_AUDIO,
-        Namespace.JINGLE_FEATURE_VIDEO,
-        Namespace.JINGLE_APPS_RTP,
-        Namespace.JINGLE_APPS_DTLS,
-        Namespace.JINGLE_MESSAGE
-    };
+
     protected XmppConnectionService mXmppConnectionService;
 
     AbstractGenerator(XmppConnectionService service) {
@@ -70,70 +24,4 @@ public abstract class AbstractGenerator {
     String getIdentityVersion() {
         return BuildConfig.VERSION_NAME;
     }
-
-    String getIdentityName() {
-        return BuildConfig.APP_NAME;
-    }
-
-    String getIdentityType() {
-        if ("chromium".equals(android.os.Build.BRAND)) {
-            return "pc";
-        } else {
-            return mXmppConnectionService.getString(R.string.default_resource).toLowerCase();
-        }
-    }
-
-    String getCapHash(final Account account) {
-        StringBuilder s = new StringBuilder();
-        s.append("client/")
-                .append(getIdentityType())
-                .append("//")
-                .append(getIdentityName())
-                .append('<');
-        MessageDigest md;
-        try {
-            md = MessageDigest.getInstance("SHA-1");
-        } catch (NoSuchAlgorithmException e) {
-            return null;
-        }
-
-        for (String feature : getFeatures(account)) {
-            s.append(feature).append('<');
-        }
-        final byte[] sha1 = md.digest(s.toString().getBytes());
-        return Base64.encodeToString(sha1, Base64.NO_WRAP);
-    }
-
-    public List<String> getFeatures(final Account account) {
-        final XmppConnection connection = account.getXmppConnection();
-        final ArrayList<String> features = new ArrayList<>(Arrays.asList(STATIC_FEATURES));
-        if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
-            features.add(Namespace.MDS_DISPLAYED + "+notify");
-        }
-        if (mXmppConnectionService.confirmMessages()) {
-            features.addAll(Arrays.asList(MESSAGE_CONFIRMATION_FEATURES));
-        }
-        if (mXmppConnectionService.allowMessageCorrection()) {
-            features.addAll(Arrays.asList(MESSAGE_CORRECTION_FEATURES));
-        }
-        if (Config.supportOmemo()) {
-            features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
-        }
-        if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) {
-            features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
-            features.addAll(Arrays.asList(VOIP_NAMESPACES));
-            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
-        }
-        if (mXmppConnectionService.broadcastLastActivity()) {
-            features.add(Namespace.IDLE);
-        }
-        if (connection != null && connection.getFeatures().bookmarks2()) {
-            features.add(Namespace.BOOKMARKS2 + "+notify");
-        } else {
-            features.add(Namespace.BOOKMARKS + "+notify");
-        }
-
-        Collections.sort(features);
-        return features;
-    }
 }
  
  
  
    
    @@ -39,22 +39,6 @@ public class IqGenerator extends AbstractGenerator {
         super(service);
     }
 
-    public Iq discoResponse(final Account account, final Iq request) {
-        final var packet = new Iq(Iq.Type.RESULT);
-        packet.setId(request.getId());
-        packet.setTo(request.getFrom());
-        final Element query = packet.addChild("query", "http://jabber.org/protocol/disco#info");
-        query.setAttribute("node", request.query().getAttribute("node"));
-        final Element identity = query.addChild("identity");
-        identity.setAttribute("category", "client");
-        identity.setAttribute("type", getIdentityType());
-        identity.setAttribute("name", getIdentityName());
-        for (final String feature : getFeatures(account)) {
-            query.addChild("feature").setAttribute("var", feature);
-        }
-        return packet;
-    }
-
     public Iq versionResponse(final Iq request) {
         final var packet = request.generateResponse(Iq.Type.RESULT);
         Element query = packet.query("jabber:iq:version");
  
  
  
    
    @@ -5,8 +5,8 @@ import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import im.conversations.android.xmpp.model.stanza.Presence;
 
 public class PresenceGenerator extends AbstractGenerator {
@@ -66,25 +66,11 @@ public class PresenceGenerator extends AbstractGenerator {
 
     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();
-            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");
-            cap.setAttribute("hash", "sha-1");
-            cap.setAttribute("node", "http://conversations.im");
-            cap.setAttribute("ver", capHash);
+        final var connection = account.getXmppConnection();
+        if (connection == null) {
+            return new Presence();
         }
-        return packet;
+        return connection.getManager(PresenceManager.class).getPresence(status, personal);
     }
 
     public im.conversations.android.xmpp.model.stanza.Presence leave(final MucOptions mucOptions) {
  
  
  
    
    @@ -17,6 +17,8 @@ import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.forms.Data;
+import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.io.ByteArrayInputStream;
 import java.security.cert.CertificateException;
@@ -412,6 +414,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
 
     @Override
     public void accept(final Iq packet) {
+        final var connection = account.getXmppConnection();
         final boolean isGet = packet.getType() == Iq.Type.GET;
         if (packet.getType() == Iq.Type.ERROR || packet.getType() == Iq.Type.TIMEOUT) {
             return;
@@ -439,7 +442,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
             // Otherwise, just update the existing blocklist.
             if (packet.getType() == Iq.Type.RESULT) {
                 account.clearBlocklist();
-                account.getXmppConnection().getFeatures().setBlockListRequested(true);
+                connection.getFeatures().setBlockListRequested(true);
             }
             if (items != null) {
                 final Collection<Jid> jids = new ArrayList<>(items.size());
@@ -499,10 +502,8 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
                 || packet.hasChild("data", "http://jabber.org/protocol/ibb")
                 || packet.hasChild("close", "http://jabber.org/protocol/ibb")) {
             mXmppConnectionService.getJingleConnectionManager().deliverIbbPacket(account, packet);
-        } else if (packet.hasChild("query", "http://jabber.org/protocol/disco#info")) {
-            final Iq response =
-                    mXmppConnectionService.getIqGenerator().discoResponse(account, packet);
-            mXmppConnectionService.sendIqPacket(account, response, null);
+        } else if (packet.hasExtension(InfoQuery.class)) {
+            connection.getManager(DiscoManager.class).handleInfoQuery(packet);
         } else if (packet.hasChild("query", "jabber:iq:version") && isGet) {
             final Iq response = mXmppConnectionService.getIqGenerator().versionResponse(packet);
             mXmppConnectionService.sendIqPacket(account, response, null);
@@ -545,7 +546,7 @@ public class IqParser extends AbstractParser implements Consumer<Iq> {
                 final Element error = response.addChild("error");
                 error.setAttribute("type", "cancel");
                 error.addChild("feature-not-implemented", "urn:ietf:params:xml:ns:xmpp-stanzas");
-                account.getXmppConnection().sendIqPacket(response, null);
+                connection.sendIqPacket(response, null);
             }
         }
     }
  
  
  
    
    @@ -5527,11 +5527,11 @@ public class XmppConnectionService extends Service {
     }
 
     public boolean confirmMessages() {
-        return getBooleanPreference("confirm_messages", R.bool.confirm_messages);
+        return appSettings.isConfirmMessages();
     }
 
     public boolean allowMessageCorrection() {
-        return getBooleanPreference("allow_message_correction", R.bool.allow_message_correction);
+        return appSettings.isAllowMessageCorrection();
     }
 
     public boolean sendChatStates() {
@@ -5539,12 +5539,11 @@ public class XmppConnectionService extends Service {
     }
 
     public boolean useTorToConnect() {
-        return QuickConversationsService.isConversations()
-                && getBooleanPreference("use_tor", R.bool.use_tor);
+        return appSettings.isUseTor();
     }
 
     public boolean broadcastLastActivity() {
-        return getBooleanPreference(AppSettings.BROADCAST_LAST_ACTIVITY, R.bool.last_activity);
+        return appSettings.isBroadcastLastActivity();
     }
 
     public int unreadCount() {
  
  
  
    
    @@ -74,10 +74,12 @@ 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 eu.siacs.conversations.xmpp.manager.PresenceManager;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.model.AuthenticationFailure;
 import im.conversations.android.xmpp.model.AuthenticationRequest;
 import im.conversations.android.xmpp.model.AuthenticationStreamFeature;
+import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.StreamElement;
 import im.conversations.android.xmpp.model.bind2.Bind;
 import im.conversations.android.xmpp.model.bind2.Bound;
@@ -224,6 +226,9 @@ public class XmppConnection implements Runnable {
                         .put(
                                 DiscoManager.class,
                                 new DiscoManager(service.getApplicationContext(), this))
+                        .put(
+                                PresenceManager.class,
+                                new PresenceManager(service.getApplicationContext(), this))
                         .build();
     }
 
@@ -2562,6 +2567,36 @@ public class XmppConnection implements Runnable {
         return packet.getId();
     }
 
+    public void sendResultFor(final Iq request, final Extension... extensions) {
+        final var from = request.getFrom();
+        final var id = request.getId();
+        final var response = new Iq(Iq.Type.RESULT);
+        response.setTo(from);
+        response.setId(id);
+        for (final Extension extension : extensions) {
+            response.addExtension(extension);
+        }
+        this.sendPacket(response);
+    }
+
+    public void sendErrorFor(
+            final Iq request,
+            final im.conversations.android.xmpp.model.error.Error.Type type,
+            final Condition condition,
+            final im.conversations.android.xmpp.model.error.Error.Extension... extensions) {
+        final var from = request.getFrom();
+        final var id = request.getId();
+        final var response = new Iq(Iq.Type.ERROR);
+        response.setTo(from);
+        response.setId(id);
+        final var error =
+                response.addExtension(new im.conversations.android.xmpp.model.error.Error());
+        error.setType(type);
+        error.setCondition(condition);
+        error.addExtensions(extensions);
+        this.sendPacket(response);
+    }
+
     public void sendMessagePacket(final im.conversations.android.xmpp.model.stanza.Message packet) {
         this.sendPacket(packet);
     }
  
  
  
    
    @@ -4,26 +4,35 @@ import android.content.Context;
 import android.util.Log;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.BaseEncoding;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import eu.siacs.conversations.AppSettings;
+import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.EntityCapabilities;
 import im.conversations.android.xmpp.EntityCapabilities2;
+import im.conversations.android.xmpp.ServiceDescription;
 import im.conversations.android.xmpp.model.Hash;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.disco.items.Item;
 import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
+import im.conversations.android.xmpp.model.error.Condition;
+import im.conversations.android.xmpp.model.error.Error;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -35,6 +44,43 @@ public class DiscoManager extends AbstractManager {
 
     public static final String CAPABILITY_NODE = "http://conversations.im";
 
+    private final List<String> STATIC_FEATURES =
+            Arrays.asList(
+                    Namespace.JINGLE,
+                    Namespace.JINGLE_APPS_FILE_TRANSFER,
+                    Namespace.JINGLE_TRANSPORTS_S5B,
+                    Namespace.JINGLE_TRANSPORTS_IBB,
+                    Namespace.JINGLE_ENCRYPTED_TRANSPORT,
+                    Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
+                    "http://jabber.org/protocol/muc",
+                    "jabber:x:conference",
+                    Namespace.OOB,
+                    Namespace.ENTITY_CAPABILITIES,
+                    Namespace.ENTITY_CAPABILITIES_2,
+                    Namespace.DISCO_INFO,
+                    "urn:xmpp:avatar:metadata+notify",
+                    Namespace.NICK + "+notify",
+                    Namespace.PING,
+                    Namespace.VERSION,
+                    Namespace.CHAT_STATES,
+                    Namespace.REACTIONS);
+    private final List<String> MESSAGE_CONFIRMATION_FEATURES =
+            Arrays.asList(Namespace.CHAT_MARKERS, Namespace.DELIVERY_RECEIPTS);
+    private final List<String> MESSAGE_CORRECTION_FEATURES =
+            Collections.singletonList(Namespace.LAST_MESSAGE_CORRECTION);
+    private final List<String> PRIVACY_SENSITIVE =
+            Collections.singletonList(
+                    "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
+                    );
+    private final List<String> VOIP_NAMESPACES =
+            Arrays.asList(
+                    Namespace.JINGLE_TRANSPORT_ICE_UDP,
+                    Namespace.JINGLE_FEATURE_AUDIO,
+                    Namespace.JINGLE_FEATURE_VIDEO,
+                    Namespace.JINGLE_APPS_RTP,
+                    Namespace.JINGLE_APPS_DTLS,
+                    Namespace.JINGLE_MESSAGE);
+
     // this is the runtime cache that stores disco information for all entities seen during a
     // session
 
@@ -92,7 +138,7 @@ public class DiscoManager extends AbstractManager {
     public ListenableFuture<Void> infoOrCache(
             final Entity entity, final String node, final EntityCapabilities.Hash hash) {
         final var cached = getDatabase().getInfoQuery(hash);
-        if (cached != null) {
+        if (cached != null && Config.ENABLE_CAPS_CACHE) {
             if (node == null || hash != null) {
                 this.put(entity.address, cached);
             }
@@ -109,7 +155,7 @@ public class DiscoManager extends AbstractManager {
 
     public ListenableFuture<InfoQuery> info(
             final Entity entity, @Nullable final String node, final EntityCapabilities.Hash hash) {
-        final var requestNode = hash != null && node != null ? hash.capabilityNode(node) : node;
+        final var requestNode = hash != null ? hash.capabilityNode(node) : node;
         final var iqRequest = new Iq(Iq.Type.GET);
         iqRequest.setTo(entity.address);
         final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
@@ -260,6 +306,87 @@ public class DiscoManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
+    ServiceDescription getServiceDescription() {
+        final var appSettings = new AppSettings(context);
+        final var account = connection.getAccount();
+        final ImmutableList.Builder<String> features = ImmutableList.builder();
+        if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
+            features.add(Namespace.MDS_DISPLAYED + "+notify");
+        }
+        if (appSettings.isConfirmMessages()) {
+            features.addAll(MESSAGE_CONFIRMATION_FEATURES);
+        }
+        if (appSettings.isAllowMessageCorrection()) {
+            features.addAll(MESSAGE_CORRECTION_FEATURES);
+        }
+        if (Config.supportOmemo()) {
+            features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
+        }
+        if (!appSettings.isUseTor() && !account.isOnion()) {
+            features.addAll(PRIVACY_SENSITIVE);
+            features.addAll(VOIP_NAMESPACES);
+            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
+        }
+        if (appSettings.isBroadcastLastActivity()) {
+            features.add(Namespace.IDLE);
+        }
+        if (connection.getFeatures().bookmarks2()) {
+            features.add(Namespace.BOOKMARKS2 + "+notify");
+        } else {
+            features.add(Namespace.BOOKMARKS + "+notify");
+        }
+        return new ServiceDescription(
+                features.build(),
+                new ServiceDescription.Identity(BuildConfig.APP_NAME, "client", getIdentityType()));
+    }
+
+    String getIdentityVersion() {
+        return BuildConfig.VERSION_NAME;
+    }
+
+    String getIdentityType() {
+        if ("chromium".equals(android.os.Build.BRAND)) {
+            return "pc";
+        } else if (context.getResources().getBoolean(R.bool.is_device_table)) {
+            return "tablet";
+        } else {
+            return "phone";
+        }
+    }
+
+    public void handleInfoQuery(final Iq request) {
+        final var infoQueryRequest = request.getExtension(InfoQuery.class);
+        final var nodeRequest = infoQueryRequest.getNode();
+        final ServiceDescription serviceDescription;
+        if (Strings.isNullOrEmpty(nodeRequest)) {
+            serviceDescription = getServiceDescription();
+            Log.d(Config.LOGTAG, "responding to disco request w/o node from " + request.getFrom());
+        } else {
+            final var hash = buildHashFromNode(nodeRequest);
+            final var cachedServiceDescription =
+                    hash != null
+                            ? getManager(PresenceManager.class).getCachedServiceDescription(hash)
+                            : null;
+            if (cachedServiceDescription != null) {
+                Log.d(
+                        Config.LOGTAG,
+                        "responding to disco request from "
+                                + request.getFrom()
+                                + " to node "
+                                + nodeRequest
+                                + " using hash "
+                                + hash.getClass().getName());
+                serviceDescription = cachedServiceDescription;
+            } else {
+                connection.sendErrorFor(request, Error.Type.CANCEL, new Condition.ItemNotFound());
+                return;
+            }
+        }
+        final var infoQuery = serviceDescription.asInfoQuery();
+        infoQuery.setNode(nodeRequest);
+        connection.sendResultFor(request, infoQuery);
+    }
+
     public Map<Jid, InfoQuery> getServerItems() {
         final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
         final var domain = connection.getAccount().getDomain();
  
  
  
    
    @@ -0,0 +1,58 @@
+package eu.siacs.conversations.xmpp.manager;
+
+import android.content.Context;
+import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.EntityCapabilities;
+import im.conversations.android.xmpp.EntityCapabilities2;
+import im.conversations.android.xmpp.ServiceDescription;
+import im.conversations.android.xmpp.model.capabilties.Capabilities;
+import im.conversations.android.xmpp.model.capabilties.LegacyCapabilities;
+import im.conversations.android.xmpp.model.pgp.Signed;
+import im.conversations.android.xmpp.model.stanza.Presence;
+import java.util.HashMap;
+import java.util.Map;
+
+public class PresenceManager extends AbstractManager {
+
+    private final Map<EntityCapabilities.Hash, ServiceDescription> serviceDescriptions =
+            new HashMap<>();
+
+    public PresenceManager(Context context, XmppConnection connection) {
+        super(context, connection);
+    }
+
+    public Presence getPresence(final Presence.Availability availability, final boolean personal) {
+        final var account = connection.getAccount();
+        final var serviceDiscoveryFeatures = getManager(DiscoManager.class).getServiceDescription();
+        final var infoQuery = serviceDiscoveryFeatures.asInfoQuery();
+        final var capsHash = EntityCapabilities.hash(infoQuery);
+        final var caps2Hash = EntityCapabilities2.hash(infoQuery);
+        serviceDescriptions.put(capsHash, serviceDiscoveryFeatures);
+        serviceDescriptions.put(caps2Hash, serviceDiscoveryFeatures);
+        final var capabilities = new Capabilities();
+        capabilities.setHash(caps2Hash);
+        final var legacyCapabilities = new LegacyCapabilities();
+        legacyCapabilities.setNode(DiscoManager.CAPABILITY_NODE);
+        legacyCapabilities.setHash(capsHash);
+        final var presence = new Presence();
+        presence.addExtension(capabilities);
+        presence.addExtension(legacyCapabilities);
+
+        if (personal) {
+            final String pgpSignature = account.getPgpSignature();
+            final String message = account.getPresenceStatusMessage();
+            presence.setAvailability(availability);
+            presence.setStatus(message);
+            if (pgpSignature != null) {
+                final var signed = new Signed();
+                signed.setContent(pgpSignature);
+                presence.addExtension(new Signed());
+            }
+        }
+        return presence;
+    }
+
+    public ServiceDescription getCachedServiceDescription(final EntityCapabilities.Hash hash) {
+        return this.serviceDescriptions.get(hash);
+    }
+}
  
  
  
    
    @@ -0,0 +1,49 @@
+package im.conversations.android.xmpp;
+
+import com.google.common.collect.Collections2;
+import im.conversations.android.xmpp.model.disco.info.Feature;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
+import java.util.Collection;
+import java.util.List;
+
+public class ServiceDescription {
+    public final List<String> features;
+    public final Identity identity;
+
+    public ServiceDescription(List<String> features, Identity identity) {
+        this.features = features;
+        this.identity = identity;
+    }
+
+    public InfoQuery asInfoQuery() {
+        final var infoQuery = new InfoQuery();
+        final Collection<Feature> features =
+                Collections2.transform(
+                        this.features,
+                        sf -> {
+                            final var feature = new Feature();
+                            feature.setVar(sf);
+                            return feature;
+                        });
+        infoQuery.addExtensions(features);
+        final var identity =
+                infoQuery.addExtension(
+                        new im.conversations.android.xmpp.model.disco.info.Identity());
+        identity.setIdentityName(this.identity.name);
+        identity.setCategory(this.identity.category);
+        identity.setType(this.identity.type);
+        return infoQuery;
+    }
+
+    public static class Identity {
+        public final String name;
+        public final String category;
+        public final String type;
+
+        public Identity(String name, String category, String type) {
+            this.name = name;
+            this.category = category;
+            this.type = type;
+        }
+    }
+}
  
  
  
    
    @@ -1,5 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="default_resource">Tablet</string>
     <bool name="portrait_only">false</bool>
-</resources>
+</resources>
  
  
  
    
    @@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <bool name="is_device_table">true</bool>
+</resources>
  
  
  
    
    @@ -1,6 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="default_resource">Phone</string>
     <bool name="portrait_only">true</bool>
     <bool name="enter_is_send">false</bool>
     <bool name="notifications_from_strangers">true</bool>
  
  
  
    
    @@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <bool name="is_device_table">false</bool>
+</resources>