001/**
002 *
003 * Copyright 2003-2007 Jive Software, 2018-2024 Florian Schmaus.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.disco;
018
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.WeakHashMap;
029import java.util.concurrent.ConcurrentHashMap;
030import java.util.concurrent.CopyOnWriteArraySet;
031import java.util.concurrent.TimeUnit;
032import java.util.concurrent.atomic.AtomicInteger;
033import java.util.logging.Level;
034import java.util.logging.Logger;
035
036import org.jivesoftware.smack.ConnectionCreationListener;
037import org.jivesoftware.smack.ConnectionListener;
038import org.jivesoftware.smack.Manager;
039import org.jivesoftware.smack.ScheduledAction;
040import org.jivesoftware.smack.SmackException.NoResponseException;
041import org.jivesoftware.smack.SmackException.NotConnectedException;
042import org.jivesoftware.smack.XMPPConnection;
043import org.jivesoftware.smack.XMPPConnectionRegistry;
044import org.jivesoftware.smack.XMPPException.XMPPErrorException;
045import org.jivesoftware.smack.filter.PresenceTypeFilter;
046import org.jivesoftware.smack.internal.AbstractStats;
047import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
048import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
049import org.jivesoftware.smack.packet.IQ;
050import org.jivesoftware.smack.packet.Presence;
051import org.jivesoftware.smack.packet.Stanza;
052import org.jivesoftware.smack.packet.StanzaError;
053import org.jivesoftware.smack.util.CollectionUtil;
054import org.jivesoftware.smack.util.ExtendedAppendable;
055import org.jivesoftware.smack.util.Objects;
056import org.jivesoftware.smack.util.StringUtils;
057
058import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
059import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity;
060import org.jivesoftware.smackx.disco.packet.DiscoverInfoBuilder;
061import org.jivesoftware.smackx.disco.packet.DiscoverItems;
062import org.jivesoftware.smackx.xdata.packet.DataForm;
063
064import org.jxmpp.jid.DomainBareJid;
065import org.jxmpp.jid.EntityBareJid;
066import org.jxmpp.jid.Jid;
067import org.jxmpp.util.cache.Cache;
068import org.jxmpp.util.cache.ExpirationCache;
069
070/**
071 * Manages discovery of services in XMPP entities. This class provides:
072 * <ol>
073 * <li>A registry of supported features in this XMPP entity.
074 * <li>Automatic response when this XMPP entity is queried for information.
075 * <li>Ability to discover items and information of remote XMPP entities.
076 * <li>Ability to publish publicly available items.
077 * </ol>
078 *
079 * @author Gaston Dombiak
080 * @author Florian Schmaus
081 */
082public final class ServiceDiscoveryManager extends Manager {
083
084    private static final Logger LOGGER = Logger.getLogger(ServiceDiscoveryManager.class.getName());
085
086    private static final String DEFAULT_IDENTITY_NAME = "Smack";
087    private static final String DEFAULT_IDENTITY_CATEGORY = "client";
088    private static final String DEFAULT_IDENTITY_TYPE = "pc";
089
090    private static final List<DiscoInfoLookupShortcutMechanism> discoInfoLookupShortcutMechanisms = new ArrayList<>(2);
091
092    private static DiscoverInfo.Identity defaultIdentity = new Identity(DEFAULT_IDENTITY_CATEGORY,
093            DEFAULT_IDENTITY_NAME, DEFAULT_IDENTITY_TYPE);
094
095    private final Set<DiscoverInfo.Identity> identities = new HashSet<>();
096    private DiscoverInfo.Identity identity = defaultIdentity;
097
098    private final Set<EntityCapabilitiesChangedListener> entityCapabilitiesChangedListeners = new CopyOnWriteArraySet<>();
099
100    private static final Map<XMPPConnection, ServiceDiscoveryManager> instances = new WeakHashMap<>();
101
102    private final Set<String> features = new HashSet<>();
103    private List<DataForm> extendedInfos = new ArrayList<>(2);
104    private final Map<String, NodeInformationProvider> nodeInformationProviders = new ConcurrentHashMap<>();
105
106    private volatile Presence presenceSend;
107
108    // Create a new ServiceDiscoveryManager on every established connection
109    static {
110        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
111            @Override
112            public void connectionCreated(XMPPConnection connection) {
113                getInstanceFor(connection);
114            }
115        });
116    }
117
118    /**
119     * Set the default identity all new connections will have. If unchanged the default identity is an
120     * identity where category is set to 'client', type is set to 'pc' and name is set to 'Smack'.
121     *
122     * @param identity TODO javadoc me please
123     */
124    public static void setDefaultIdentity(DiscoverInfo.Identity identity) {
125        defaultIdentity = identity;
126    }
127
128    /**
129     * Creates a new ServiceDiscoveryManager for a given XMPPConnection. This means that the
130     * service manager will respond to any service discovery request that the connection may
131     * receive.
132     *
133     * @param connection the connection to which a ServiceDiscoveryManager is going to be created.
134     */
135    private ServiceDiscoveryManager(XMPPConnection connection) {
136        super(connection);
137
138        addFeature(DiscoverInfo.NAMESPACE);
139        addFeature(DiscoverItems.NAMESPACE);
140
141        // Listen for disco#items requests and answer with an empty result
142        connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverItems.ELEMENT, DiscoverItems.NAMESPACE, IQ.Type.get, Mode.async) {
143            @Override
144            public IQ handleIQRequest(IQ iqRequest) {
145                DiscoverItems discoverItems = (DiscoverItems) iqRequest;
146                DiscoverItems response = new DiscoverItems();
147                response.setType(IQ.Type.result);
148                response.setTo(discoverItems.getFrom());
149                response.setStanzaId(discoverItems.getStanzaId());
150                response.setNode(discoverItems.getNode());
151
152                // Add the defined items related to the requested node. Look for
153                // the NodeInformationProvider associated with the requested node.
154                NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverItems.getNode());
155                if (nodeInformationProvider != null) {
156                    // Specified node was found, add node items
157                    response.addItems(nodeInformationProvider.getNodeItems());
158                    // Add packet extensions
159                    response.addExtensions(nodeInformationProvider.getNodePacketExtensions());
160                } else if (discoverItems.getNode() != null) {
161                    // Return <item-not-found/> error since client doesn't contain
162                    // the specified node
163                    response.setType(IQ.Type.error);
164                    response.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build());
165                }
166                return response;
167            }
168        });
169
170        // Listen for disco#info requests and answer the client's supported features
171        // To add a new feature as supported use the #addFeature message
172        connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverInfo.ELEMENT, DiscoverInfo.NAMESPACE, IQ.Type.get, Mode.async) {
173            @Override
174            public IQ handleIQRequest(IQ iqRequest) {
175                DiscoverInfo discoverInfo = (DiscoverInfo) iqRequest;
176                // Answer the client's supported features if the request is of the GET type
177                DiscoverInfoBuilder responseBuilder = DiscoverInfoBuilder.buildResponseFor(discoverInfo, IQ.ResponseType.result);
178
179                // Add the client's identity and features only if "node" is null
180                // and if the request was not send to a node. If Entity Caps are
181                // enabled the client's identity and features are may also added
182                // if the right node is chosen
183                if (discoverInfo.getNode() == null) {
184                    addDiscoverInfoTo(responseBuilder);
185                } else {
186                    // Disco#info was sent to a node. Check if we have information of the
187                    // specified node
188                    NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverInfo.getNode());
189                    if (nodeInformationProvider != null) {
190                        // Node was found. Add node features
191                        responseBuilder.addFeatures(nodeInformationProvider.getNodeFeatures());
192                        // Add node identities
193                        responseBuilder.addIdentities(nodeInformationProvider.getNodeIdentities());
194                        // Add packet extensions
195                        responseBuilder.addOptExtensions(nodeInformationProvider.getNodePacketExtensions());
196                    } else {
197                        // Return <item-not-found/> error since specified node was not found
198                        responseBuilder.ofType(IQ.Type.error);
199                        responseBuilder.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build());
200                    }
201                }
202
203                DiscoverInfo response = responseBuilder.build();
204                return response;
205            }
206        });
207
208        connection.addConnectionListener(new ConnectionListener() {
209            @Override
210            public void authenticated(XMPPConnection connection, boolean resumed) {
211                // Reset presenceSend when the connection was not resumed
212                if (!resumed) {
213                    presenceSend = null;
214                }
215            }
216        });
217        connection.addStanzaSendingListener(p -> presenceSend = (Presence) p,
218                        PresenceTypeFilter.OUTGOING_PRESENCE_BROADCAST);
219    }
220
221    /**
222     * Returns the name of the client that will be returned when asked for the client identity
223     * in a disco request. The name could be any value you need to identity this client.
224     *
225     * @return the name of the client that will be returned when asked for the client identity
226     *          in a disco request.
227     */
228    public String getIdentityName() {
229        return identity.getName();
230    }
231
232    /**
233     * Sets the default identity the client will report.
234     *
235     * @param identity TODO javadoc me please
236     */
237    public synchronized void setIdentity(Identity identity) {
238        this.identity = Objects.requireNonNull(identity, "Identity can not be null");
239        // Notify others of a state change of SDM. In order to keep the state consistent, this
240        // method is synchronized
241        renewEntityCapsVersion();
242    }
243
244    /**
245     * Return the default identity of the client.
246     *
247     * @return the default identity.
248     */
249    public Identity getIdentity() {
250        return identity;
251    }
252
253    /**
254     * Returns the type of client that will be returned when asked for the client identity in a
255     * disco request. The valid types are defined by the category client. Follow this link to learn
256     * the possible types: <a href="https://xmpp.org/registrar/disco-categories.html">XMPP Registry for Service Discovery Identities</a>
257     *
258     * @return the type of client that will be returned when asked for the client identity in a
259     *          disco request.
260     */
261    public String getIdentityType() {
262        return identity.getType();
263    }
264
265    /**
266     * Add an further identity to the client.
267     *
268     * @param identity TODO javadoc me please
269     */
270    public synchronized void addIdentity(DiscoverInfo.Identity identity) {
271        identities.add(identity);
272        // Notify others of a state change of SDM. In order to keep the state consistent, this
273        // method is synchronized
274        renewEntityCapsVersion();
275    }
276
277    /**
278     * Remove an identity from the client. Note that the client needs at least one identity, the default identity, which
279     * can not be removed.
280     *
281     * @param identity TODO javadoc me please
282     * @return true, if successful. Otherwise the default identity was given.
283     */
284    public synchronized boolean removeIdentity(DiscoverInfo.Identity identity) {
285        if (identity.equals(this.identity)) return false;
286        identities.remove(identity);
287        // Notify others of a state change of SDM. In order to keep the state consistent, this
288        // method is synchronized
289        renewEntityCapsVersion();
290        return true;
291    }
292
293    /**
294     * Returns all identities of this client as unmodifiable Collection.
295     *
296     * @return all identities as a set
297     */
298    public Set<DiscoverInfo.Identity> getIdentities() {
299        Set<Identity> res = new HashSet<>(identities);
300        // Add the main identity that must exist
301        res.add(identity);
302        return Collections.unmodifiableSet(res);
303    }
304
305    /**
306     * Returns the ServiceDiscoveryManager instance associated with a given XMPPConnection.
307     *
308     * @param connection the connection used to look for the proper ServiceDiscoveryManager.
309     * @return the ServiceDiscoveryManager associated with a given XMPPConnection.
310     */
311    public static synchronized ServiceDiscoveryManager getInstanceFor(XMPPConnection connection) {
312        ServiceDiscoveryManager sdm = instances.get(connection);
313        if (sdm == null) {
314            sdm = new ServiceDiscoveryManager(connection);
315            // Register the new instance and associate it with the connection
316            instances.put(connection, sdm);
317        }
318        return sdm;
319    }
320
321    /**
322     * Add discover info response data.
323     *
324     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-basic">XEP-30 Basic Protocol; Example 2</a>
325     *
326     * @param response the discover info response packet
327     */
328    public synchronized void addDiscoverInfoTo(DiscoverInfoBuilder response) {
329        // First add the identities of the connection
330        response.addIdentities(getIdentities());
331
332        // Add the registered features to the response
333        for (String feature : getFeatures()) {
334            response.addFeature(feature);
335        }
336
337        response.addExtensions(extendedInfos);
338    }
339
340    /**
341     * Returns the NodeInformationProvider responsible for providing information
342     * (ie items) related to a given node or <code>null</null> if none.<p>
343     *
344     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
345     * NodeInformationProvider will provide information about the rooms where the user has joined.
346     *
347     * @param node the node that contains items associated with an entity not addressable as a JID.
348     * @return the NodeInformationProvider responsible for providing information related
349     * to a given node.
350     */
351    private NodeInformationProvider getNodeInformationProvider(String node) {
352        if (node == null) {
353            return null;
354        }
355        return nodeInformationProviders.get(node);
356    }
357
358    /**
359     * Sets the NodeInformationProvider responsible for providing information
360     * (ie items) related to a given node. Every time this client receives a disco request
361     * regarding the items of a given node, the provider associated to that node will be the
362     * responsible for providing the requested information.<p>
363     *
364     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
365     * NodeInformationProvider will provide information about the rooms where the user has joined.
366     *
367     * @param node the node whose items will be provided by the NodeInformationProvider.
368     * @param listener the NodeInformationProvider responsible for providing items related
369     *      to the node.
370     */
371    public void setNodeInformationProvider(String node, NodeInformationProvider listener) {
372        nodeInformationProviders.put(node, listener);
373    }
374
375    /**
376     * Removes the NodeInformationProvider responsible for providing information
377     * (ie items) related to a given node. This means that no more information will be
378     * available for the specified node.
379     *
380     * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the
381     * NodeInformationProvider will provide information about the rooms where the user has joined.
382     *
383     * @param node the node to remove the associated NodeInformationProvider.
384     */
385    public void removeNodeInformationProvider(String node) {
386        nodeInformationProviders.remove(node);
387    }
388
389    /**
390     * Returns the supported features by this XMPP entity.
391     * <p>
392     * The result is a copied modifiable list of the original features.
393     * </p>
394     *
395     * @return a List of the supported features by this XMPP entity.
396     */
397    public synchronized List<String> getFeatures() {
398        return new ArrayList<>(features);
399    }
400
401    /**
402     * Registers that a new feature is supported by this XMPP entity. When this client is
403     * queried for its information the registered features will be answered.<p>
404     *
405     * Since no stanza is actually sent to the server it is safe to perform this operation
406     * before logging to the server. In fact, you may want to configure the supported features
407     * before logging to the server so that the information is already available if it is required
408     * upon login.
409     *
410     * @param feature the feature to register as supported.
411     */
412    public synchronized void addFeature(String feature) {
413        features.add(feature);
414        // Notify others of a state change of SDM. In order to keep the state consistent, this
415        // method is synchronized
416        renewEntityCapsVersion();
417    }
418
419    /**
420     * Removes the specified feature from the supported features by this XMPP entity.<p>
421     *
422     * Since no stanza is actually sent to the server it is safe to perform this operation
423     * before logging to the server.
424     *
425     * @param feature the feature to remove from the supported features.
426     */
427    public synchronized void removeFeature(String feature) {
428        features.remove(feature);
429        // Notify others of a state change of SDM. In order to keep the state consistent, this
430        // method is synchronized
431        renewEntityCapsVersion();
432    }
433
434    /**
435     * Returns true if the specified feature is registered in the ServiceDiscoveryManager.
436     *
437     * @param feature the feature to look for.
438     * @return a boolean indicating if the specified featured is registered or not.
439     */
440    public synchronized boolean includesFeature(String feature) {
441        return features.contains(feature);
442    }
443
444    /**
445     * Registers extended discovery information of this XMPP entity. When this
446     * client is queried for its information this data form will be returned as
447     * specified by XEP-0128.
448     * <p>
449     *
450     * Since no stanza is actually sent to the server it is safe to perform this
451     * operation before logging to the server. In fact, you may want to
452     * configure the extended info before logging to the server so that the
453     * information is already available if it is required upon login.
454     *
455     * @param extendedInfo the data form that contains the extend service discovery information.
456     * @return the old data form which got replaced (if any)
457     * @since 4.4.0
458     */
459    public DataForm addExtendedInfo(DataForm extendedInfo) {
460        String formType = extendedInfo.getFormType();
461        StringUtils.requireNotNullNorEmpty(formType, "The data form must have a form type set");
462
463        DataForm removedDataForm;
464        synchronized (this) {
465            removedDataForm = DataForm.remove(extendedInfos, formType);
466
467            extendedInfos.add(extendedInfo);
468
469            // Notify others of a state change of SDM. In order to keep the state consistent, this
470            // method is synchronized
471            renewEntityCapsVersion();
472        }
473        return removedDataForm;
474    }
475
476    /**
477     * Remove the extended discovery information of the given form type.
478     *
479     * @param formType the type of the data form with the extended discovery information to remove.
480     * @since 4.4.0
481     */
482    public synchronized void removeExtendedInfo(String formType) {
483        DataForm removedForm = DataForm.remove(extendedInfos, formType);
484        if (removedForm != null) {
485            renewEntityCapsVersion();
486        }
487    }
488
489    /**
490     * Returns the data form as List of PacketExtensions, or null if no data form is set.
491     * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider)
492     *
493     * @return the data form as List of PacketExtensions
494     */
495    public synchronized List<DataForm> getExtendedInfo() {
496        return CollectionUtil.newListWith(extendedInfos);
497    }
498
499    /**
500     * Removes the data form containing extended service discovery information
501     * from the information returned by this XMPP entity.<p>
502     *
503     * Since no stanza is actually sent to the server it is safe to perform this
504     * operation before logging to the server.
505     */
506    public synchronized void removeExtendedInfo() {
507        int extendedInfosCount = extendedInfos.size();
508        extendedInfos.clear();
509        if (extendedInfosCount > 0) {
510            // Notify others of a state change of SDM. In order to keep the state consistent, this
511            // method is synchronized
512            renewEntityCapsVersion();
513        }
514    }
515
516    /**
517     * Returns the discovered information of a given XMPP entity addressed by its JID.
518     * Use null as entityID to query the server
519     *
520     * @param entityID the address of the XMPP entity or null.
521     * @return the discovered information.
522     * @throws XMPPErrorException if there was an XMPP error returned.
523     * @throws NoResponseException if there was no response from the remote entity.
524     * @throws NotConnectedException if the XMPP connection is not connected.
525     * @throws InterruptedException if the calling thread was interrupted.
526     */
527    public DiscoverInfo discoverInfo(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
528        if (entityID == null)
529            return discoverInfo(null, null);
530
531        synchronized (discoInfoLookupShortcutMechanisms) {
532            for (DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism : discoInfoLookupShortcutMechanisms) {
533                DiscoverInfo info = discoInfoLookupShortcutMechanism.getDiscoverInfoByUser(this, entityID);
534                if (info != null) {
535                    // We were able to retrieve the information from Entity Caps and
536                    // avoided a disco request, hurray!
537                    return info;
538                }
539            }
540        }
541
542        // Last resort: Standard discovery.
543        return discoverInfo(entityID, null);
544    }
545
546    /**
547     * Returns the discovered information of a given XMPP entity addressed by its JID and
548     * note attribute. Use this message only when trying to query information which is not
549     * directly addressable.
550     *
551     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-basic">XEP-30 Basic Protocol</a>
552     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-nodes">XEP-30 Info Nodes</a>
553     *
554     * @param entityID the address of the XMPP entity.
555     * @param node the optional attribute that supplements the 'jid' attribute.
556     * @return the discovered information.
557     * @throws XMPPErrorException if the operation failed for some reason.
558     * @throws NoResponseException if there was no response from the server.
559     * @throws NotConnectedException if the XMPP connection is not connected.
560     * @throws InterruptedException if the calling thread was interrupted.
561     */
562    public DiscoverInfo discoverInfo(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
563        XMPPConnection connection = connection();
564
565        // Discover the entity's info
566        DiscoverInfo discoInfoRequest = DiscoverInfo.builder(connection)
567                .to(entityID)
568                .setNode(node)
569                .build();
570
571        Stanza result = connection.sendIqRequestAndWaitForResponse(discoInfoRequest);
572
573        return (DiscoverInfo) result;
574    }
575
576    /**
577     * Returns the discovered items of a given XMPP entity addressed by its JID.
578     *
579     * @param entityID the address of the XMPP entity.
580     * @return the discovered information.
581     * @throws XMPPErrorException if the operation failed for some reason.
582     * @throws NoResponseException if there was no response from the server.
583     * @throws NotConnectedException if the XMPP connection is not connected.
584     * @throws InterruptedException if the calling thread was interrupted.
585     */
586    public DiscoverItems discoverItems(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
587        return discoverItems(entityID, null);
588    }
589
590    /**
591     * Returns the discovered items of a given XMPP entity addressed by its JID and
592     * note attribute. Use this message only when trying to query information which is not
593     * directly addressable.
594     *
595     * @param entityID the address of the XMPP entity.
596     * @param node the optional attribute that supplements the 'jid' attribute.
597     * @return the discovered items.
598     * @throws XMPPErrorException if the operation failed for some reason.
599     * @throws NoResponseException if there was no response from the server.
600     * @throws NotConnectedException if the XMPP connection is not connected.
601     * @throws InterruptedException if the calling thread was interrupted.
602     */
603    public DiscoverItems discoverItems(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
604        // Discover the entity's items
605        DiscoverItems disco = new DiscoverItems();
606        disco.setType(IQ.Type.get);
607        disco.setTo(entityID);
608        disco.setNode(node);
609
610        Stanza result = connection().sendIqRequestAndWaitForResponse(disco);
611        return (DiscoverItems) result;
612    }
613
614    /**
615     * Returns true if the server supports the given feature.
616     *
617     * @param feature TODO javadoc me please
618     * @return true if the server supports the given feature.
619     * @throws NoResponseException if there was no response from the remote entity.
620     * @throws XMPPErrorException if there was an XMPP error returned.
621     * @throws NotConnectedException if the XMPP connection is not connected.
622     * @throws InterruptedException if the calling thread was interrupted.
623     * @since 4.1
624     */
625    public boolean serverSupportsFeature(CharSequence feature) throws NoResponseException, XMPPErrorException,
626                    NotConnectedException, InterruptedException {
627        return serverSupportsFeatures(feature);
628    }
629
630    public boolean serverSupportsFeatures(CharSequence... features) throws NoResponseException,
631                    XMPPErrorException, NotConnectedException, InterruptedException {
632        return serverSupportsFeatures(Arrays.asList(features));
633    }
634
635    public boolean serverSupportsFeatures(Collection<? extends CharSequence> features)
636                    throws NoResponseException, XMPPErrorException, NotConnectedException,
637                    InterruptedException {
638        return supportsFeatures(connection().getXMPPServiceDomain(), features);
639    }
640
641    /**
642     * Check if the given features are supported by the connection account. This means that the discovery information
643     * lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
644     *
645     * @param features the features to check
646     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
647     * @throws NoResponseException if there was no response from the remote entity.
648     * @throws XMPPErrorException if there was an XMPP error returned.
649     * @throws NotConnectedException if the XMPP connection is not connected.
650     * @throws InterruptedException if the calling thread was interrupted.
651     * @since 4.2.2
652     */
653    public boolean accountSupportsFeatures(CharSequence... features)
654                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
655        return accountSupportsFeatures(Arrays.asList(features));
656    }
657
658    /**
659     * Check if the given collection of features are supported by the connection account. This means that the discovery
660     * information lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
661     *
662     * @param features a collection of features
663     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
664     * @throws NoResponseException if there was no response from the remote entity.
665     * @throws XMPPErrorException if there was an XMPP error returned.
666     * @throws NotConnectedException if the XMPP connection is not connected.
667     * @throws InterruptedException if the calling thread was interrupted.
668     * @since 4.2.2
669     */
670    public boolean accountSupportsFeatures(Collection<? extends CharSequence> features)
671                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
672        EntityBareJid accountJid = connection().getUser().asEntityBareJid();
673        return supportsFeatures(accountJid, features);
674    }
675
676    /**
677     * Queries the remote entity for it's features and returns true if the given feature is found.
678     *
679     * @param jid the JID of the remote entity
680     * @param feature TODO javadoc me please
681     * @return true if the entity supports the feature, false otherwise
682     * @throws XMPPErrorException if there was an XMPP error returned.
683     * @throws NoResponseException if there was no response from the remote entity.
684     * @throws NotConnectedException if the XMPP connection is not connected.
685     * @throws InterruptedException if the calling thread was interrupted.
686     */
687    public boolean supportsFeature(Jid jid, CharSequence feature) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
688        return supportsFeatures(jid, feature);
689    }
690
691    public boolean supportsFeatures(Jid jid, CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
692        return supportsFeatures(jid, Arrays.asList(features));
693    }
694
695    public boolean supportsFeatures(Jid jid, Collection<? extends CharSequence> features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
696        DiscoverInfo result = discoverInfo(jid);
697        for (CharSequence feature : features) {
698            if (!result.containsFeature(feature)) {
699                return false;
700            }
701        }
702        return true;
703    }
704
705    /**
706     * Create a cache to hold the 25 most recently lookup services for a given feature for a period
707     * of 24 hours.
708     */
709    private final Cache<String, List<DiscoverInfo>> services = new ExpirationCache<>(25,
710                    24 * 60 * 60 * 1000);
711
712    /**
713     * Find all services under the users service that provide a given feature.
714     *
715     * @param feature the feature to search for
716     * @param stopOnFirst if true, stop searching after the first service was found
717     * @param useCache if true, query a cache first to avoid network I/O
718     * @return a possible empty list of services providing the given feature
719     * @throws NoResponseException if there was no response from the remote entity.
720     * @throws XMPPErrorException if there was an XMPP error returned.
721     * @throws NotConnectedException if the XMPP connection is not connected.
722     * @throws InterruptedException if the calling thread was interrupted.
723     */
724    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache)
725                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
726        return findServicesDiscoverInfo(feature, stopOnFirst, useCache, null);
727    }
728
729    /**
730     * Find all services under the users service that provide a given feature.
731     *
732     * @param feature the feature to search for
733     * @param stopOnFirst if true, stop searching after the first service was found
734     * @param useCache if true, query a cache first to avoid network I/O
735     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
736     * @return a possible empty list of services providing the given feature
737     * @throws NoResponseException if there was no response from the remote entity.
738     * @throws XMPPErrorException if there was an XMPP error returned.
739     * @throws NotConnectedException if the XMPP connection is not connected.
740     * @throws InterruptedException if the calling thread was interrupted.
741     * @since 4.2.2
742     */
743    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
744                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
745        DomainBareJid serviceName = connection().getXMPPServiceDomain();
746        return findServicesDiscoverInfo(serviceName, feature, stopOnFirst, useCache, encounteredExceptions);
747    }
748
749    /**
750     * Find all services under a given service that provide a given feature.
751     *
752     * @param serviceName the service to query
753     * @param feature the feature to search for
754     * @param stopOnFirst if true, stop searching after the first service was found
755     * @param useCache if true, query a cache first to avoid network I/O
756     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
757     * @return a possible empty list of services providing the given feature
758     * @throws NoResponseException if there was no response from the remote entity.
759     * @throws XMPPErrorException if there was an XMPP error returned.
760     * @throws NotConnectedException if the XMPP connection is not connected.
761     * @throws InterruptedException if the calling thread was interrupted.
762     * @since 4.3.0
763     */
764    public List<DiscoverInfo> findServicesDiscoverInfo(DomainBareJid serviceName, String feature, boolean stopOnFirst,
765                    boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
766            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
767        List<DiscoverInfo> serviceDiscoInfo;
768        if (useCache) {
769            serviceDiscoInfo = services.lookup(feature);
770            if (serviceDiscoInfo != null) {
771                return serviceDiscoInfo;
772            }
773        }
774        serviceDiscoInfo = new ArrayList<>();
775        // Send the disco packet to the server itself
776        DiscoverInfo info;
777        try {
778            info = discoverInfo(serviceName);
779        } catch (XMPPErrorException e) {
780            if (encounteredExceptions != null) {
781                encounteredExceptions.put(serviceName, e);
782            }
783            return serviceDiscoInfo;
784        }
785        // Check if the server supports the feature
786        if (info.containsFeature(feature)) {
787            serviceDiscoInfo.add(info);
788            if (stopOnFirst) {
789                if (useCache) {
790                    // Cache the discovered information
791                    services.put(feature, serviceDiscoInfo);
792                }
793                return serviceDiscoInfo;
794            }
795        }
796        DiscoverItems items;
797        try {
798            // Get the disco items and send the disco packet to each server item
799            items = discoverItems(serviceName);
800        } catch (XMPPErrorException e) {
801            if (encounteredExceptions != null) {
802                encounteredExceptions.put(serviceName, e);
803            }
804            return serviceDiscoInfo;
805        }
806        for (DiscoverItems.Item item : items.getItems()) {
807            Jid address = item.getEntityID();
808            try {
809                // TODO is it OK here in all cases to query without the node attribute?
810                // MultipleRecipientManager queried initially also with the node attribute, but this
811                // could be simply a fault instead of intentional.
812                info = discoverInfo(address);
813            }
814            catch (XMPPErrorException | NoResponseException e) {
815                if (encounteredExceptions != null) {
816                    encounteredExceptions.put(address, e);
817                }
818                continue;
819            }
820            if (info.containsFeature(feature)) {
821                serviceDiscoInfo.add(info);
822                if (stopOnFirst) {
823                    break;
824                }
825            }
826        }
827        if (useCache) {
828            // Cache the discovered information
829            services.put(feature, serviceDiscoInfo);
830        }
831        return serviceDiscoInfo;
832    }
833
834    /**
835     * Find all services under the users service that provide a given feature.
836     *
837     * @param feature the feature to search for
838     * @param stopOnFirst if true, stop searching after the first service was found
839     * @param useCache if true, query a cache first to avoid network I/O
840     * @return a possible empty list of services providing the given feature
841     * @throws NoResponseException if there was no response from the remote entity.
842     * @throws XMPPErrorException if there was an XMPP error returned.
843     * @throws NotConnectedException if the XMPP connection is not connected.
844     * @throws InterruptedException if the calling thread was interrupted.
845     */
846    public List<DomainBareJid> findServices(String feature, boolean stopOnFirst, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
847        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, stopOnFirst, useCache);
848        List<DomainBareJid> res = new ArrayList<>(services.size());
849        for (DiscoverInfo info : services) {
850            res.add(info.getFrom().asDomainBareJid());
851        }
852        return res;
853    }
854
855    public DomainBareJid findService(String feature, boolean useCache, String category, String type)
856                    throws NoResponseException, XMPPErrorException, NotConnectedException,
857                    InterruptedException {
858        boolean noCategory = StringUtils.isNullOrEmpty(category);
859        boolean noType = StringUtils.isNullOrEmpty(type);
860        if (noType != noCategory) {
861            throw new IllegalArgumentException("Must specify either both, category and type, or none");
862        }
863
864        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, false, useCache);
865        if (services.isEmpty()) {
866            return null;
867        }
868
869        if (!noCategory && !noType) {
870            for (DiscoverInfo info : services) {
871                if (info.hasIdentity(category, type)) {
872                    return info.getFrom().asDomainBareJid();
873                }
874            }
875        }
876
877        return services.get(0).getFrom().asDomainBareJid();
878    }
879
880    public DomainBareJid findService(String feature, boolean useCache) throws NoResponseException,
881                    XMPPErrorException, NotConnectedException, InterruptedException {
882        return findService(feature, useCache, null, null);
883    }
884
885    public boolean addEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
886        return entityCapabilitiesChangedListeners.add(entityCapabilitiesChangedListener);
887    }
888
889    public boolean removeEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
890        return entityCapabilitiesChangedListeners.remove(entityCapabilitiesChangedListener);
891    }
892
893    private static final int RENEW_ENTITY_CAPS_DELAY_MILLIS = 25;
894
895    private ScheduledAction renewEntityCapsScheduledAction;
896
897    private final AtomicInteger renewEntityCapsPerformed = new AtomicInteger();
898    private int renewEntityCapsRequested = 0;
899    private int scheduledRenewEntityCapsAvoided = 0;
900
901    /**
902     * Notify the {@link EntityCapabilitiesChangedListener} about changed capabilities.
903     */
904    private synchronized void renewEntityCapsVersion() {
905        if (entityCapabilitiesChangedListeners.isEmpty()) {
906            return;
907        }
908
909        renewEntityCapsRequested++;
910        if (renewEntityCapsScheduledAction != null) {
911            boolean canceled = renewEntityCapsScheduledAction.cancel();
912            if (canceled) {
913                scheduledRenewEntityCapsAvoided++;
914            }
915        }
916
917        renewEntityCapsScheduledAction = scheduleBlocking(() -> {
918            final XMPPConnection connection = connection();
919            if (connection == null) {
920                return;
921            }
922
923            renewEntityCapsPerformed.incrementAndGet();
924
925            DiscoverInfoBuilder discoverInfoBuilder = DiscoverInfo.builder("synthetized-disco-info-response")
926                            .ofType(IQ.Type.result);
927            addDiscoverInfoTo(discoverInfoBuilder);
928            DiscoverInfo synthesizedDiscoveryInfo = discoverInfoBuilder.build();
929
930            for (EntityCapabilitiesChangedListener entityCapabilitiesChangedListener : entityCapabilitiesChangedListeners) {
931                entityCapabilitiesChangedListener.onEntityCapabilitiesChanged(synthesizedDiscoveryInfo);
932            }
933
934            // Re-send the last sent presence, and let the stanza interceptor
935            // add a <c/> node to it.
936            // See http://xmpp.org/extensions/xep-0115.html#advertise
937            // We only send a presence packet if there was already one send
938            // to respect ConnectionConfiguration.isSendPresence()
939            final Presence presenceSend = this.presenceSend;
940            if (connection.isAuthenticated() && presenceSend != null) {
941                Presence presence = presenceSend.asBuilder(connection).build();
942                try {
943                    connection.sendStanza(presence);
944                }
945                catch (InterruptedException | NotConnectedException e) {
946                    LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e);
947                }
948            }
949        }, RENEW_ENTITY_CAPS_DELAY_MILLIS, TimeUnit.MILLISECONDS);
950    }
951
952    public static void addDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
953        synchronized (discoInfoLookupShortcutMechanisms) {
954            discoInfoLookupShortcutMechanisms.add(discoInfoLookupShortcutMechanism);
955            Collections.sort(discoInfoLookupShortcutMechanisms);
956        }
957    }
958
959    public static void removeDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
960        synchronized (discoInfoLookupShortcutMechanisms) {
961            discoInfoLookupShortcutMechanisms.remove(discoInfoLookupShortcutMechanism);
962        }
963    }
964
965    public synchronized Stats getStats() {
966        return new Stats(this);
967    }
968
969    public static final class Stats extends AbstractStats {
970
971        public final int renewEntityCapsRequested;
972        public final int renewEntityCapsPerformed;
973        public final int scheduledRenewEntityCapsAvoided;
974
975        private Stats(ServiceDiscoveryManager serviceDiscoveryManager) {
976            renewEntityCapsRequested = serviceDiscoveryManager.renewEntityCapsRequested;
977            renewEntityCapsPerformed = serviceDiscoveryManager.renewEntityCapsPerformed.get();
978            scheduledRenewEntityCapsAvoided = serviceDiscoveryManager.scheduledRenewEntityCapsAvoided;
979        }
980
981        @Override
982        public void appendStatsTo(ExtendedAppendable appendable) throws IOException {
983            StringUtils.appendHeading(appendable, "ServiceDiscoveryManager stats", '#').append('\n');
984            appendable.append("renew-entitycaps-requested: ").append(renewEntityCapsRequested).append('\n');
985            appendable.append("renew-entitycaps-performed: ").append(renewEntityCapsPerformed).append('\n');
986            appendable.append("scheduled-renew-entitycaps-avoided: ").append(scheduledRenewEntityCapsAvoided).append('\n');
987        }
988
989    }
990}