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