ServiceDiscoveryManager.java

/**
 *
 * Copyright 2003-2007 Jive Software, 2018-2022 Florian Schmaus.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jivesoftware.smackx.disco;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.jivesoftware.smack.ConnectionCreationListener;
import org.jivesoftware.smack.ConnectionListener;
import org.jivesoftware.smack.Manager;
import org.jivesoftware.smack.ScheduledAction;
import org.jivesoftware.smack.SmackException.NoResponseException;
import org.jivesoftware.smack.SmackException.NotConnectedException;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPConnectionRegistry;
import org.jivesoftware.smack.XMPPException.XMPPErrorException;
import org.jivesoftware.smack.filter.PresenceTypeFilter;
import org.jivesoftware.smack.internal.AbstractStats;
import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
import org.jivesoftware.smack.packet.IQ;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.packet.StanzaError;
import org.jivesoftware.smack.util.CollectionUtil;
import org.jivesoftware.smack.util.ExtendedAppendable;
import org.jivesoftware.smack.util.Objects;
import org.jivesoftware.smack.util.StringUtils;

import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity;
import org.jivesoftware.smackx.disco.packet.DiscoverInfoBuilder;
import org.jivesoftware.smackx.disco.packet.DiscoverItems;
import org.jivesoftware.smackx.xdata.packet.DataForm;

import org.jxmpp.jid.DomainBareJid;
import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.Jid;
import org.jxmpp.util.cache.Cache;
import org.jxmpp.util.cache.ExpirationCache;

/**
 * Manages discovery of services in XMPP entities. This class provides:
 * <ol>
 * <li>A registry of supported features in this XMPP entity.
 * <li>Automatic response when this XMPP entity is queried for information.
 * <li>Ability to discover items and information of remote XMPP entities.
 * <li>Ability to publish publicly available items.
 * </ol>
 *
 * @author Gaston Dombiak
 * @author Florian Schmaus
 */
public final class ServiceDiscoveryManager extends Manager {

    private static final Logger LOGGER = Logger.getLogger(ServiceDiscoveryManager.class.getName());

    private static final String DEFAULT_IDENTITY_NAME = "Smack";
    private static final String DEFAULT_IDENTITY_CATEGORY = "client";
    private static final String DEFAULT_IDENTITY_TYPE = "pc";

    private static final List<DiscoInfoLookupShortcutMechanism> discoInfoLookupShortcutMechanisms = new ArrayList<>(2);

    private static DiscoverInfo.Identity defaultIdentity = new Identity(DEFAULT_IDENTITY_CATEGORY,
            DEFAULT_IDENTITY_NAME, DEFAULT_IDENTITY_TYPE);

    private final Set<DiscoverInfo.Identity> identities = new HashSet<>();
    private DiscoverInfo.Identity identity = defaultIdentity;

    private final Set<EntityCapabilitiesChangedListener> entityCapabilitiesChangedListeners = new CopyOnWriteArraySet<>();

    private static final Map<XMPPConnection, ServiceDiscoveryManager> instances = new WeakHashMap<>();

    private final Set<String> features = new HashSet<>();
    private List<DataForm> extendedInfos = new ArrayList<>(2);
    private final Map<String, NodeInformationProvider> nodeInformationProviders = new ConcurrentHashMap<>();

    private volatile Presence presenceSend;

    // Create a new ServiceDiscoveryManager on every established connection
    static {
        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
            @Override
            public void connectionCreated(XMPPConnection connection) {
                getInstanceFor(connection);
            }
        });
    }

    /**
     * Set the default identity all new connections will have. If unchanged the default identity is an
     * identity where category is set to 'client', type is set to 'pc' and name is set to 'Smack'.
     *
     * @param identity TODO javadoc me please
     */
    public static void setDefaultIdentity(DiscoverInfo.Identity identity) {
        defaultIdentity = identity;
    }

    /**
     * Creates a new ServiceDiscoveryManager for a given XMPPConnection. This means that the
     * service manager will respond to any service discovery request that the connection may
     * receive.
     *
     * @param connection the connection to which a ServiceDiscoveryManager is going to be created.
     */
    private ServiceDiscoveryManager(XMPPConnection connection) {
        super(connection);

        addFeature(DiscoverInfo.NAMESPACE);
        addFeature(DiscoverItems.NAMESPACE);

        // Listen for disco#items requests and answer with an empty result
        connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverItems.ELEMENT, DiscoverItems.NAMESPACE, IQ.Type.get, Mode.async) {
            @Override
            public IQ handleIQRequest(IQ iqRequest) {
                DiscoverItems discoverItems = (DiscoverItems) iqRequest;
                DiscoverItems response = new DiscoverItems();
                response.setType(IQ.Type.result);
                response.setTo(discoverItems.getFrom());
                response.setStanzaId(discoverItems.getStanzaId());
                response.setNode(discoverItems.getNode());

                // Add the defined items related to the requested node. Look for
                // the NodeInformationProvider associated with the requested node.
                NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverItems.getNode());
                if (nodeInformationProvider != null) {
                    // Specified node was found, add node items
                    response.addItems(nodeInformationProvider.getNodeItems());
                    // Add packet extensions
                    response.addExtensions(nodeInformationProvider.getNodePacketExtensions());
                } else if (discoverItems.getNode() != null) {
                    // Return <item-not-found/> error since client doesn't contain
                    // the specified node
                    response.setType(IQ.Type.error);
                    response.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build());
                }
                return response;
            }
        });

        // Listen for disco#info requests and answer the client's supported features
        // To add a new feature as supported use the #addFeature message
        connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverInfo.ELEMENT, DiscoverInfo.NAMESPACE, IQ.Type.get, Mode.async) {
            @Override
            public IQ handleIQRequest(IQ iqRequest) {
                DiscoverInfo discoverInfo = (DiscoverInfo) iqRequest;
                // Answer the client's supported features if the request is of the GET type
                DiscoverInfoBuilder responseBuilder = DiscoverInfoBuilder.buildResponseFor(discoverInfo, IQ.ResponseType.result);

                // Add the client's identity and features only if "node" is null
                // and if the request was not send to a node. If Entity Caps are
                // enabled the client's identity and features are may also added
                // if the right node is chosen
                if (discoverInfo.getNode() == null) {
                    addDiscoverInfoTo(responseBuilder);
                } else {
                    // Disco#info was sent to a node. Check if we have information of the
                    // specified node
                    NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverInfo.getNode());
                    if (nodeInformationProvider != null) {
                        // Node was found. Add node features
                        responseBuilder.addFeatures(nodeInformationProvider.getNodeFeatures());
                        // Add node identities
                        responseBuilder.addIdentities(nodeInformationProvider.getNodeIdentities());
                        // Add packet extensions
                        responseBuilder.addOptExtensions(nodeInformationProvider.getNodePacketExtensions());
                    } else {
                        // Return <item-not-found/> error since specified node was not found
                        responseBuilder.ofType(IQ.Type.error);
                        responseBuilder.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build());
                    }
                }

                DiscoverInfo response = responseBuilder.build();
                return response;
            }
        });

        connection.addConnectionListener(new ConnectionListener() {
            @Override
            public void authenticated(XMPPConnection connection, boolean resumed) {
                // Reset presenceSend when the connection was not resumed
                if (!resumed) {
                    presenceSend = null;
                }
            }
        });
        connection.addStanzaSendingListener(p -> presenceSend = (Presence) p,
                        PresenceTypeFilter.OUTGOING_PRESENCE_BROADCAST);
    }

    /**
     * Returns the name of the client that will be returned when asked for the client identity
     * in a disco request. The name could be any value you need to identity this client.
     *
     * @return the name of the client that will be returned when asked for the client identity
     *          in a disco request.
     */
    public String getIdentityName() {
        return identity.getName();
    }

    /**
     * Sets the default identity the client will report.
     *
     * @param identity TODO javadoc me please
     */
    public synchronized void setIdentity(Identity identity) {
        this.identity = Objects.requireNonNull(identity, "Identity can not be null");
        // Notify others of a state change of SDM. In order to keep the state consistent, this
        // method is synchronized
        renewEntityCapsVersion();
    }

    /**
     * Return the default identity of the client.
     *
     * @return the default identity.
     */
    public Identity getIdentity() {
        return identity;
    }

    /**
     * Returns the type of client that will be returned when asked for the client identity in a
     * disco request. The valid types are defined by the category client. Follow this link to learn
     * the possible types: <a href="https://xmpp.org/registrar/disco-categories.html">XMPP Registry for Service Discovery Identities</a>
     *
     * @return the type of client that will be returned when asked for the client identity in a
     *          disco request.
     */
    public String getIdentityType() {
        return identity.getType();
    }

    /**
     * Add an further identity to the client.
     *
     * @param identity TODO javadoc me please
     */
    public synchronized void addIdentity(DiscoverInfo.Identity identity) {
        identities.add(identity);
        // Notify others of a state change of SDM. In order to keep the state consistent, this
        // method is synchronized
        renewEntityCapsVersion();
    }

    /**
     * Remove an identity from the client. Note that the client needs at least one identity, the default identity, which
     * can not be removed.
     *
     * @param identity TODO javadoc me please
     * @return true, if successful. Otherwise the default identity was given.
     */
    public synchronized boolean removeIdentity(DiscoverInfo.Identity identity) {
        if (identity.equals(this.identity)) return false;
        identities.remove(identity);
        // Notify others of a state change of SDM. In order to keep the state consistent, this
        // method is synchronized
        renewEntityCapsVersion();
        return true;
    }

    /**
     * Returns all identities of this client as unmodifiable Collection.
     *
     * @return all identies as set
     */
    public Set<DiscoverInfo.Identity> getIdentities() {
        Set<Identity> res = new HashSet<>(identities);
        // Add the main identity that must exist
        res.add(identity);
        return Collections.unmodifiableSet(res);
    }

    /**
     * Returns the ServiceDiscoveryManager instance associated with a given XMPPConnection.
     *
     * @param connection the connection used to look for the proper ServiceDiscoveryManager.
     * @return the ServiceDiscoveryManager associated with a given XMPPConnection.
     */
    public static synchronized ServiceDiscoveryManager getInstanceFor(XMPPConnection connection) {
        ServiceDiscoveryManager sdm = instances.get(connection);
        if (sdm == null) {
            sdm = new ServiceDiscoveryManager(connection);
            // Register the new instance and associate it with the connection
            instances.put(connection, sdm);
        }
        return sdm;
    }

    /**
     * Add discover info response data.
     *
     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-basic">XEP-30 Basic Protocol; Example 2</a>
     *
     * @param response the discover info response packet
     */
    public synchronized void addDiscoverInfoTo(DiscoverInfoBuilder response) {
        // First add the identities of the connection
        response.addIdentities(getIdentities());

        // Add the registered features to the response
        for (String feature : getFeatures()) {
            response.addFeature(feature);
        }

        response.addExtensions(extendedInfos);
    }

    /**
     * Returns the NodeInformationProvider responsible for providing information
     * (ie items) related to a given node or <code>null</null> if none.<p>
     *
     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
     * NodeInformationProvider will provide information about the rooms where the user has joined.
     *
     * @param node the node that contains items associated with an entity not addressable as a JID.
     * @return the NodeInformationProvider responsible for providing information related
     * to a given node.
     */
    private NodeInformationProvider getNodeInformationProvider(String node) {
        if (node == null) {
            return null;
        }
        return nodeInformationProviders.get(node);
    }

    /**
     * Sets the NodeInformationProvider responsible for providing information
     * (ie items) related to a given node. Every time this client receives a disco request
     * regarding the items of a given node, the provider associated to that node will be the
     * responsible for providing the requested information.<p>
     *
     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
     * NodeInformationProvider will provide information about the rooms where the user has joined.
     *
     * @param node the node whose items will be provided by the NodeInformationProvider.
     * @param listener the NodeInformationProvider responsible for providing items related
     *      to the node.
     */
    public void setNodeInformationProvider(String node, NodeInformationProvider listener) {
        nodeInformationProviders.put(node, listener);
    }

    /**
     * Removes the NodeInformationProvider responsible for providing information
     * (ie items) related to a given node. This means that no more information will be
     * available for the specified node.
     *
     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
     * NodeInformationProvider will provide information about the rooms where the user has joined.
     *
     * @param node the node to remove the associated NodeInformationProvider.
     */
    public void removeNodeInformationProvider(String node) {
        nodeInformationProviders.remove(node);
    }

    /**
     * Returns the supported features by this XMPP entity.
     * <p>
     * The result is a copied modifiable list of the original features.
     * </p>
     *
     * @return a List of the supported features by this XMPP entity.
     */
    public synchronized List<String> getFeatures() {
        return new ArrayList<>(features);
    }

    /**
     * Registers that a new feature is supported by this XMPP entity. When this client is
     * queried for its information the registered features will be answered.<p>
     *
     * Since no stanza is actually sent to the server it is safe to perform this operation
     * before logging to the server. In fact, you may want to configure the supported features
     * before logging to the server so that the information is already available if it is required
     * upon login.
     *
     * @param feature the feature to register as supported.
     */
    public synchronized void addFeature(String feature) {
        features.add(feature);
        // Notify others of a state change of SDM. In order to keep the state consistent, this
        // method is synchronized
        renewEntityCapsVersion();
    }

    /**
     * Removes the specified feature from the supported features by this XMPP entity.<p>
     *
     * Since no stanza is actually sent to the server it is safe to perform this operation
     * before logging to the server.
     *
     * @param feature the feature to remove from the supported features.
     */
    public synchronized void removeFeature(String feature) {
        features.remove(feature);
        // Notify others of a state change of SDM. In order to keep the state consistent, this
        // method is synchronized
        renewEntityCapsVersion();
    }

    /**
     * Returns true if the specified feature is registered in the ServiceDiscoveryManager.
     *
     * @param feature the feature to look for.
     * @return a boolean indicating if the specified featured is registered or not.
     */
    public synchronized boolean includesFeature(String feature) {
        return features.contains(feature);
    }

    /**
     * Registers extended discovery information of this XMPP entity. When this
     * client is queried for its information this data form will be returned as
     * specified by XEP-0128.
     * <p>
     *
     * Since no stanza is actually sent to the server it is safe to perform this
     * operation before logging to the server. In fact, you may want to
     * configure the extended info before logging to the server so that the
     * information is already available if it is required upon login.
     *
     * @param info the data form that contains the extend service discovery
     *            information.
     * @deprecated use {@link #addExtendedInfo(DataForm)} instead.
     */
    // TODO: Remove in Smack 4.5
    @Deprecated
    public synchronized void setExtendedInfo(DataForm info) {
        addExtendedInfo(info);
    }

    /**
     * Registers extended discovery information of this XMPP entity. When this
     * client is queried for its information this data form will be returned as
     * specified by XEP-0128.
     * <p>
     *
     * Since no stanza is actually sent to the server it is safe to perform this
     * operation before logging to the server. In fact, you may want to
     * configure the extended info before logging to the server so that the
     * information is already available if it is required upon login.
     *
     * @param extendedInfo the data form that contains the extend service discovery information.
     * @return the old data form which got replaced (if any)
     * @since 4.4.0
     */
    public DataForm addExtendedInfo(DataForm extendedInfo) {
        String formType = extendedInfo.getFormType();
        StringUtils.requireNotNullNorEmpty(formType, "The data form must have a form type set");

        DataForm removedDataForm;
        synchronized (this) {
            removedDataForm = DataForm.remove(extendedInfos, formType);

            extendedInfos.add(extendedInfo);

            // Notify others of a state change of SDM. In order to keep the state consistent, this
            // method is synchronized
            renewEntityCapsVersion();
        }
        return removedDataForm;
    }

    /**
     * Remove the extended discovery information of the given form type.
     *
     * @param formType the type of the data form with the extended discovery information to remove.
     * @since 4.4.0
     */
    public synchronized void removeExtendedInfo(String formType) {
        DataForm removedForm = DataForm.remove(extendedInfos, formType);
        if (removedForm != null) {
            renewEntityCapsVersion();
        }
    }

    /**
     * Returns the data form as List of PacketExtensions, or null if no data form is set.
     * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider)
     *
     * @return the data form as List of PacketExtensions
     */
    public synchronized List<DataForm> getExtendedInfo() {
        return CollectionUtil.newListWith(extendedInfos);
    }

    /**
     * Returns the data form as List of PacketExtensions, or null if no data form is set.
     * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider)
     *
     * @return the data form as List of PacketExtensions
     * @deprecated use {@link #getExtendedInfo()} instead.
     */
    // TODO: Remove in Smack 4.5
    @Deprecated
    public List<DataForm> getExtendedInfoAsList() {
        return getExtendedInfo();
    }

    /**
     * Removes the data form containing extended service discovery information
     * from the information returned by this XMPP entity.<p>
     *
     * Since no stanza is actually sent to the server it is safe to perform this
     * operation before logging to the server.
     */
    public synchronized void removeExtendedInfo() {
        int extendedInfosCount = extendedInfos.size();
        extendedInfos.clear();
        if (extendedInfosCount > 0) {
            // Notify others of a state change of SDM. In order to keep the state consistent, this
            // method is synchronized
            renewEntityCapsVersion();
        }
    }

    /**
     * Returns the discovered information of a given XMPP entity addressed by its JID.
     * Use null as entityID to query the server
     *
     * @param entityID the address of the XMPP entity or null.
     * @return the discovered information.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public DiscoverInfo discoverInfo(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        if (entityID == null)
            return discoverInfo(null, null);

        synchronized (discoInfoLookupShortcutMechanisms) {
            for (DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism : discoInfoLookupShortcutMechanisms) {
                DiscoverInfo info = discoInfoLookupShortcutMechanism.getDiscoverInfoByUser(this, entityID);
                if (info != null) {
                    // We were able to retrieve the information from Entity Caps and
                    // avoided a disco request, hurray!
                    return info;
                }
            }
        }

        // Last resort: Standard discovery.
        return discoverInfo(entityID, null);
    }

    /**
     * Returns the discovered information of a given XMPP entity addressed by its JID and
     * note attribute. Use this message only when trying to query information which is not
     * directly addressable.
     *
     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-basic">XEP-30 Basic Protocol</a>
     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-nodes">XEP-30 Info Nodes</a>
     *
     * @param entityID the address of the XMPP entity.
     * @param node the optional attribute that supplements the 'jid' attribute.
     * @return the discovered information.
     * @throws XMPPErrorException if the operation failed for some reason.
     * @throws NoResponseException if there was no response from the server.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public DiscoverInfo discoverInfo(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        XMPPConnection connection = connection();

        // Discover the entity's info
        DiscoverInfo discoInfoRequest = DiscoverInfo.builder(connection)
                .to(entityID)
                .setNode(node)
                .build();

        Stanza result = connection.sendIqRequestAndWaitForResponse(discoInfoRequest);

        return (DiscoverInfo) result;
    }

    /**
     * Returns the discovered items of a given XMPP entity addressed by its JID.
     *
     * @param entityID the address of the XMPP entity.
     * @return the discovered information.
     * @throws XMPPErrorException if the operation failed for some reason.
     * @throws NoResponseException if there was no response from the server.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public DiscoverItems discoverItems(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
        return discoverItems(entityID, null);
    }

    /**
     * Returns the discovered items of a given XMPP entity addressed by its JID and
     * note attribute. Use this message only when trying to query information which is not
     * directly addressable.
     *
     * @param entityID the address of the XMPP entity.
     * @param node the optional attribute that supplements the 'jid' attribute.
     * @return the discovered items.
     * @throws XMPPErrorException if the operation failed for some reason.
     * @throws NoResponseException if there was no response from the server.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public DiscoverItems discoverItems(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        // Discover the entity's items
        DiscoverItems disco = new DiscoverItems();
        disco.setType(IQ.Type.get);
        disco.setTo(entityID);
        disco.setNode(node);

        Stanza result = connection().sendIqRequestAndWaitForResponse(disco);
        return (DiscoverItems) result;
    }

    /**
     * Returns true if the server supports the given feature.
     *
     * @param feature TODO javadoc me please
     * @return true if the server supports the given feature.
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     * @since 4.1
     */
    public boolean serverSupportsFeature(CharSequence feature) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, InterruptedException {
        return serverSupportsFeatures(feature);
    }

    public boolean serverSupportsFeatures(CharSequence... features) throws NoResponseException,
                    XMPPErrorException, NotConnectedException, InterruptedException {
        return serverSupportsFeatures(Arrays.asList(features));
    }

    public boolean serverSupportsFeatures(Collection<? extends CharSequence> features)
                    throws NoResponseException, XMPPErrorException, NotConnectedException,
                    InterruptedException {
        return supportsFeatures(connection().getXMPPServiceDomain(), features);
    }

    /**
     * Check if the given features are supported by the connection account. This means that the discovery information
     * lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
     *
     * @param features the features to check
     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     * @since 4.2.2
     */
    public boolean accountSupportsFeatures(CharSequence... features)
                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        return accountSupportsFeatures(Arrays.asList(features));
    }

    /**
     * Check if the given collection of features are supported by the connection account. This means that the discovery
     * information lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
     *
     * @param features a collection of features
     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     * @since 4.2.2
     */
    public boolean accountSupportsFeatures(Collection<? extends CharSequence> features)
                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        EntityBareJid accountJid = connection().getUser().asEntityBareJid();
        return supportsFeatures(accountJid, features);
    }

    /**
     * Queries the remote entity for it's features and returns true if the given feature is found.
     *
     * @param jid the JID of the remote entity
     * @param feature TODO javadoc me please
     * @return true if the entity supports the feature, false otherwise
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public boolean supportsFeature(Jid jid, CharSequence feature) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        return supportsFeatures(jid, feature);
    }

    public boolean supportsFeatures(Jid jid, CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        return supportsFeatures(jid, Arrays.asList(features));
    }

    public boolean supportsFeatures(Jid jid, Collection<? extends CharSequence> features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        DiscoverInfo result = discoverInfo(jid);
        for (CharSequence feature : features) {
            if (!result.containsFeature(feature)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Create a cache to hold the 25 most recently lookup services for a given feature for a period
     * of 24 hours.
     */
    private final Cache<String, List<DiscoverInfo>> services = new ExpirationCache<>(25,
                    24 * 60 * 60 * 1000);

    /**
     * Find all services under the users service that provide a given feature.
     *
     * @param feature the feature to search for
     * @param stopOnFirst if true, stop searching after the first service was found
     * @param useCache if true, query a cache first to avoid network I/O
     * @return a possible empty list of services providing the given feature
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache)
                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        return findServicesDiscoverInfo(feature, stopOnFirst, useCache, null);
    }

    /**
     * Find all services under the users service that provide a given feature.
     *
     * @param feature the feature to search for
     * @param stopOnFirst if true, stop searching after the first service was found
     * @param useCache if true, query a cache first to avoid network I/O
     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
     * @return a possible empty list of services providing the given feature
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     * @since 4.2.2
     */
    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        DomainBareJid serviceName = connection().getXMPPServiceDomain();
        return findServicesDiscoverInfo(serviceName, feature, stopOnFirst, useCache, encounteredExceptions);
    }

    /**
     * Find all services under a given service that provide a given feature.
     *
     * @param serviceName the service to query
     * @param feature the feature to search for
     * @param stopOnFirst if true, stop searching after the first service was found
     * @param useCache if true, query a cache first to avoid network I/O
     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
     * @return a possible empty list of services providing the given feature
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     * @since 4.3.0
     */
    public List<DiscoverInfo> findServicesDiscoverInfo(DomainBareJid serviceName, String feature, boolean stopOnFirst,
                    boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        List<DiscoverInfo> serviceDiscoInfo;
        if (useCache) {
            serviceDiscoInfo = services.lookup(feature);
            if (serviceDiscoInfo != null) {
                return serviceDiscoInfo;
            }
        }
        serviceDiscoInfo = new LinkedList<>();
        // Send the disco packet to the server itself
        DiscoverInfo info;
        try {
            info = discoverInfo(serviceName);
        } catch (XMPPErrorException e) {
            if (encounteredExceptions != null) {
                encounteredExceptions.put(serviceName, e);
            }
            return serviceDiscoInfo;
        }
        // Check if the server supports the feature
        if (info.containsFeature(feature)) {
            serviceDiscoInfo.add(info);
            if (stopOnFirst) {
                if (useCache) {
                    // Cache the discovered information
                    services.put(feature, serviceDiscoInfo);
                }
                return serviceDiscoInfo;
            }
        }
        DiscoverItems items;
        try {
            // Get the disco items and send the disco packet to each server item
            items = discoverItems(serviceName);
        } catch (XMPPErrorException e) {
            if (encounteredExceptions != null) {
                encounteredExceptions.put(serviceName, e);
            }
            return serviceDiscoInfo;
        }
        for (DiscoverItems.Item item : items.getItems()) {
            Jid address = item.getEntityID();
            try {
                // TODO is it OK here in all cases to query without the node attribute?
                // MultipleRecipientManager queried initially also with the node attribute, but this
                // could be simply a fault instead of intentional.
                info = discoverInfo(address);
            }
            catch (XMPPErrorException | NoResponseException e) {
                if (encounteredExceptions != null) {
                    encounteredExceptions.put(address, e);
                }
                continue;
            }
            if (info.containsFeature(feature)) {
                serviceDiscoInfo.add(info);
                if (stopOnFirst) {
                    break;
                }
            }
        }
        if (useCache) {
            // Cache the discovered information
            services.put(feature, serviceDiscoInfo);
        }
        return serviceDiscoInfo;
    }

    /**
     * Find all services under the users service that provide a given feature.
     *
     * @param feature the feature to search for
     * @param stopOnFirst if true, stop searching after the first service was found
     * @param useCache if true, query a cache first to avoid network I/O
     * @return a possible empty list of services providing the given feature
     * @throws NoResponseException if there was no response from the remote entity.
     * @throws XMPPErrorException if there was an XMPP error returned.
     * @throws NotConnectedException if the XMPP connection is not connected.
     * @throws InterruptedException if the calling thread was interrupted.
     */
    public List<DomainBareJid> findServices(String feature, boolean stopOnFirst, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, stopOnFirst, useCache);
        List<DomainBareJid> res = new ArrayList<>(services.size());
        for (DiscoverInfo info : services) {
            res.add(info.getFrom().asDomainBareJid());
        }
        return res;
    }

    public DomainBareJid findService(String feature, boolean useCache, String category, String type)
                    throws NoResponseException, XMPPErrorException, NotConnectedException,
                    InterruptedException {
        boolean noCategory = StringUtils.isNullOrEmpty(category);
        boolean noType = StringUtils.isNullOrEmpty(type);
        if (noType != noCategory) {
            throw new IllegalArgumentException("Must specify either both, category and type, or none");
        }

        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, false, useCache);
        if (services.isEmpty()) {
            return null;
        }

        if (!noCategory && !noType) {
            for (DiscoverInfo info : services) {
                if (info.hasIdentity(category, type)) {
                    return info.getFrom().asDomainBareJid();
                }
            }
        }

        return services.get(0).getFrom().asDomainBareJid();
    }

    public DomainBareJid findService(String feature, boolean useCache) throws NoResponseException,
                    XMPPErrorException, NotConnectedException, InterruptedException {
        return findService(feature, useCache, null, null);
    }

    public boolean addEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
        return entityCapabilitiesChangedListeners.add(entityCapabilitiesChangedListener);
    }

    public boolean removeEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
        return entityCapabilitiesChangedListeners.remove(entityCapabilitiesChangedListener);
    }

    private static final int RENEW_ENTITY_CAPS_DELAY_MILLIS = 25;

    private ScheduledAction renewEntityCapsScheduledAction;

    private final AtomicInteger renewEntityCapsPerformed = new AtomicInteger();
    private int renewEntityCapsRequested = 0;
    private int scheduledRenewEntityCapsAvoided = 0;

    /**
     * Notify the {@link EntityCapabilitiesChangedListener} about changed capabilities.
     */
    private synchronized void renewEntityCapsVersion() {
        if (entityCapabilitiesChangedListeners.isEmpty()) {
            return;
        }

        renewEntityCapsRequested++;
        if (renewEntityCapsScheduledAction != null) {
            boolean canceled = renewEntityCapsScheduledAction.cancel();
            if (canceled) {
                scheduledRenewEntityCapsAvoided++;
            }
        }

        renewEntityCapsScheduledAction = scheduleBlocking(() -> {
            final XMPPConnection connection = connection();
            if (connection == null) {
                return;
            }

            renewEntityCapsPerformed.incrementAndGet();

            DiscoverInfoBuilder discoverInfoBuilder = DiscoverInfo.builder("synthetized-disco-info-response")
                            .ofType(IQ.Type.result);
            addDiscoverInfoTo(discoverInfoBuilder);
            DiscoverInfo synthesizedDiscoveryInfo = discoverInfoBuilder.build();

            for (EntityCapabilitiesChangedListener entityCapabilitiesChangedListener : entityCapabilitiesChangedListeners) {
                entityCapabilitiesChangedListener.onEntityCapabilitiesChanged(synthesizedDiscoveryInfo);
            }

            // Re-send the last sent presence, and let the stanza interceptor
            // add a <c/> node to it.
            // See http://xmpp.org/extensions/xep-0115.html#advertise
            // We only send a presence packet if there was already one send
            // to respect ConnectionConfiguration.isSendPresence()
            final Presence presenceSend = this.presenceSend;
            if (connection.isAuthenticated() && presenceSend != null) {
                Presence presence = presenceSend.asBuilder(connection).build();
                try {
                    connection.sendStanza(presence);
                }
                catch (InterruptedException | NotConnectedException e) {
                    LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e);
                }
            }
        }, RENEW_ENTITY_CAPS_DELAY_MILLIS, TimeUnit.MILLISECONDS);
    }

    public static void addDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
        synchronized (discoInfoLookupShortcutMechanisms) {
            discoInfoLookupShortcutMechanisms.add(discoInfoLookupShortcutMechanism);
            Collections.sort(discoInfoLookupShortcutMechanisms);
        }
    }

    public static void removeDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
        synchronized (discoInfoLookupShortcutMechanisms) {
            discoInfoLookupShortcutMechanisms.remove(discoInfoLookupShortcutMechanism);
        }
    }

    public synchronized Stats getStats() {
        return new Stats(this);
    }

    public static final class Stats extends AbstractStats {

        public final int renewEntityCapsRequested;
        public final int renewEntityCapsPerformed;
        public final int scheduledRenewEntityCapsAvoided;

        private Stats(ServiceDiscoveryManager serviceDiscoveryManager) {
            renewEntityCapsRequested = serviceDiscoveryManager.renewEntityCapsRequested;
            renewEntityCapsPerformed = serviceDiscoveryManager.renewEntityCapsPerformed.get();
            scheduledRenewEntityCapsAvoided = serviceDiscoveryManager.scheduledRenewEntityCapsAvoided;
        }

        @Override
        public void appendStatsTo(ExtendedAppendable appendable) throws IOException {
            StringUtils.appendHeading(appendable, "ServiceDiscoveryManager stats", '#').append('\n');
            appendable.append("renew-entitycaps-requested: ").append(renewEntityCapsRequested).append('\n');
            appendable.append("renew-entitycaps-performed: ").append(renewEntityCapsPerformed).append('\n');
            appendable.append("scheduled-renew-entitycaps-avoided: ").append(scheduledRenewEntityCapsAvoided).append('\n');
        }

    }
}