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.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 identities as a 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 extendedInfo the data form that contains the extend service discovery information.
457     * @return the old data form which got replaced (if any)
458     * @since 4.4.0
459     */
460    public DataForm addExtendedInfo(DataForm extendedInfo) {
461        String formType = extendedInfo.getFormType();
462        StringUtils.requireNotNullNorEmpty(formType, "The data form must have a form type set");
463
464        DataForm removedDataForm;
465        synchronized (this) {
466            removedDataForm = DataForm.remove(extendedInfos, formType);
467
468            extendedInfos.add(extendedInfo);
469
470            // Notify others of a state change of SDM. In order to keep the state consistent, this
471            // method is synchronized
472            renewEntityCapsVersion();
473        }
474        return removedDataForm;
475    }
476
477    /**
478     * Remove the extended discovery information of the given form type.
479     *
480     * @param formType the type of the data form with the extended discovery information to remove.
481     * @since 4.4.0
482     */
483    public synchronized void removeExtendedInfo(String formType) {
484        DataForm removedForm = DataForm.remove(extendedInfos, formType);
485        if (removedForm != null) {
486            renewEntityCapsVersion();
487        }
488    }
489
490    /**
491     * Returns the data form as List of PacketExtensions, or null if no data form is set.
492     * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider)
493     *
494     * @return the data form as List of PacketExtensions
495     */
496    public synchronized List<DataForm> getExtendedInfo() {
497        return CollectionUtil.newListWith(extendedInfos);
498    }
499
500    /**
501     * Removes the data form containing extended service discovery information
502     * from the information returned by this XMPP entity.<p>
503     *
504     * Since no stanza is actually sent to the server it is safe to perform this
505     * operation before logging to the server.
506     */
507    public synchronized void removeExtendedInfo() {
508        int extendedInfosCount = extendedInfos.size();
509        extendedInfos.clear();
510        if (extendedInfosCount > 0) {
511            // Notify others of a state change of SDM. In order to keep the state consistent, this
512            // method is synchronized
513            renewEntityCapsVersion();
514        }
515    }
516
517    /**
518     * Returns the discovered information of a given XMPP entity addressed by its JID.
519     * Use null as entityID to query the server
520     *
521     * @param entityID the address of the XMPP entity or null.
522     * @return the discovered information.
523     * @throws XMPPErrorException if there was an XMPP error returned.
524     * @throws NoResponseException if there was no response from the remote entity.
525     * @throws NotConnectedException if the XMPP connection is not connected.
526     * @throws InterruptedException if the calling thread was interrupted.
527     */
528    public DiscoverInfo discoverInfo(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
529        if (entityID == null)
530            return discoverInfo(null, null);
531
532        synchronized (discoInfoLookupShortcutMechanisms) {
533            for (DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism : discoInfoLookupShortcutMechanisms) {
534                DiscoverInfo info = discoInfoLookupShortcutMechanism.getDiscoverInfoByUser(this, entityID);
535                if (info != null) {
536                    // We were able to retrieve the information from Entity Caps and
537                    // avoided a disco request, hurray!
538                    return info;
539                }
540            }
541        }
542
543        // Last resort: Standard discovery.
544        return discoverInfo(entityID, null);
545    }
546
547    /**
548     * Returns the discovered information of a given XMPP entity addressed by its JID and
549     * note attribute. Use this message only when trying to query information which is not
550     * directly addressable.
551     *
552     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-basic">XEP-30 Basic Protocol</a>
553     * @see <a href="http://xmpp.org/extensions/xep-0030.html#info-nodes">XEP-30 Info Nodes</a>
554     *
555     * @param entityID the address of the XMPP entity.
556     * @param node the optional attribute that supplements the 'jid' attribute.
557     * @return the discovered information.
558     * @throws XMPPErrorException if the operation failed for some reason.
559     * @throws NoResponseException if there was no response from the server.
560     * @throws NotConnectedException if the XMPP connection is not connected.
561     * @throws InterruptedException if the calling thread was interrupted.
562     */
563    public DiscoverInfo discoverInfo(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
564        XMPPConnection connection = connection();
565
566        // Discover the entity's info
567        DiscoverInfo discoInfoRequest = DiscoverInfo.builder(connection)
568                .to(entityID)
569                .setNode(node)
570                .build();
571
572        Stanza result = connection.sendIqRequestAndWaitForResponse(discoInfoRequest);
573
574        return (DiscoverInfo) result;
575    }
576
577    /**
578     * Returns the discovered items of a given XMPP entity addressed by its JID.
579     *
580     * @param entityID the address of the XMPP entity.
581     * @return the discovered information.
582     * @throws XMPPErrorException if the operation failed for some reason.
583     * @throws NoResponseException if there was no response from the server.
584     * @throws NotConnectedException if the XMPP connection is not connected.
585     * @throws InterruptedException if the calling thread was interrupted.
586     */
587    public DiscoverItems discoverItems(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
588        return discoverItems(entityID, null);
589    }
590
591    /**
592     * Returns the discovered items of a given XMPP entity addressed by its JID and
593     * note attribute. Use this message only when trying to query information which is not
594     * directly addressable.
595     *
596     * @param entityID the address of the XMPP entity.
597     * @param node the optional attribute that supplements the 'jid' attribute.
598     * @return the discovered items.
599     * @throws XMPPErrorException if the operation failed for some reason.
600     * @throws NoResponseException if there was no response from the server.
601     * @throws NotConnectedException if the XMPP connection is not connected.
602     * @throws InterruptedException if the calling thread was interrupted.
603     */
604    public DiscoverItems discoverItems(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
605        // Discover the entity's items
606        DiscoverItems disco = new DiscoverItems();
607        disco.setType(IQ.Type.get);
608        disco.setTo(entityID);
609        disco.setNode(node);
610
611        Stanza result = connection().sendIqRequestAndWaitForResponse(disco);
612        return (DiscoverItems) result;
613    }
614
615    /**
616     * Returns true if the server supports the given feature.
617     *
618     * @param feature TODO javadoc me please
619     * @return true if the server supports the given feature.
620     * @throws NoResponseException if there was no response from the remote entity.
621     * @throws XMPPErrorException if there was an XMPP error returned.
622     * @throws NotConnectedException if the XMPP connection is not connected.
623     * @throws InterruptedException if the calling thread was interrupted.
624     * @since 4.1
625     */
626    public boolean serverSupportsFeature(CharSequence feature) throws NoResponseException, XMPPErrorException,
627                    NotConnectedException, InterruptedException {
628        return serverSupportsFeatures(feature);
629    }
630
631    public boolean serverSupportsFeatures(CharSequence... features) throws NoResponseException,
632                    XMPPErrorException, NotConnectedException, InterruptedException {
633        return serverSupportsFeatures(Arrays.asList(features));
634    }
635
636    public boolean serverSupportsFeatures(Collection<? extends CharSequence> features)
637                    throws NoResponseException, XMPPErrorException, NotConnectedException,
638                    InterruptedException {
639        return supportsFeatures(connection().getXMPPServiceDomain(), features);
640    }
641
642    /**
643     * Check if the given features are supported by the connection account. This means that the discovery information
644     * lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
645     *
646     * @param features the features to check
647     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
648     * @throws NoResponseException if there was no response from the remote entity.
649     * @throws XMPPErrorException if there was an XMPP error returned.
650     * @throws NotConnectedException if the XMPP connection is not connected.
651     * @throws InterruptedException if the calling thread was interrupted.
652     * @since 4.2.2
653     */
654    public boolean accountSupportsFeatures(CharSequence... features)
655                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
656        return accountSupportsFeatures(Arrays.asList(features));
657    }
658
659    /**
660     * Check if the given collection of features are supported by the connection account. This means that the discovery
661     * information lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager.
662     *
663     * @param features a collection of features
664     * @return <code>true</code> if all features are supported by the connection account, <code>false</code> otherwise
665     * @throws NoResponseException if there was no response from the remote entity.
666     * @throws XMPPErrorException if there was an XMPP error returned.
667     * @throws NotConnectedException if the XMPP connection is not connected.
668     * @throws InterruptedException if the calling thread was interrupted.
669     * @since 4.2.2
670     */
671    public boolean accountSupportsFeatures(Collection<? extends CharSequence> features)
672                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
673        EntityBareJid accountJid = connection().getUser().asEntityBareJid();
674        return supportsFeatures(accountJid, features);
675    }
676
677    /**
678     * Queries the remote entity for it's features and returns true if the given feature is found.
679     *
680     * @param jid the JID of the remote entity
681     * @param feature TODO javadoc me please
682     * @return true if the entity supports the feature, false otherwise
683     * @throws XMPPErrorException if there was an XMPP error returned.
684     * @throws NoResponseException if there was no response from the remote entity.
685     * @throws NotConnectedException if the XMPP connection is not connected.
686     * @throws InterruptedException if the calling thread was interrupted.
687     */
688    public boolean supportsFeature(Jid jid, CharSequence feature) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
689        return supportsFeatures(jid, feature);
690    }
691
692    public boolean supportsFeatures(Jid jid, CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
693        return supportsFeatures(jid, Arrays.asList(features));
694    }
695
696    public boolean supportsFeatures(Jid jid, Collection<? extends CharSequence> features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
697        DiscoverInfo result = discoverInfo(jid);
698        for (CharSequence feature : features) {
699            if (!result.containsFeature(feature)) {
700                return false;
701            }
702        }
703        return true;
704    }
705
706    /**
707     * Create a cache to hold the 25 most recently lookup services for a given feature for a period
708     * of 24 hours.
709     */
710    private final Cache<String, List<DiscoverInfo>> services = new ExpirationCache<>(25,
711                    24 * 60 * 60 * 1000);
712
713    /**
714     * Find all services under the users service that provide a given feature.
715     *
716     * @param feature the feature to search for
717     * @param stopOnFirst if true, stop searching after the first service was found
718     * @param useCache if true, query a cache first to avoid network I/O
719     * @return a possible empty list of services providing the given feature
720     * @throws NoResponseException if there was no response from the remote entity.
721     * @throws XMPPErrorException if there was an XMPP error returned.
722     * @throws NotConnectedException if the XMPP connection is not connected.
723     * @throws InterruptedException if the calling thread was interrupted.
724     */
725    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache)
726                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
727        return findServicesDiscoverInfo(feature, stopOnFirst, useCache, null);
728    }
729
730    /**
731     * Find all services under the users service that provide a given feature.
732     *
733     * @param feature the feature to search for
734     * @param stopOnFirst if true, stop searching after the first service was found
735     * @param useCache if true, query a cache first to avoid network I/O
736     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
737     * @return a possible empty list of services providing the given feature
738     * @throws NoResponseException if there was no response from the remote entity.
739     * @throws XMPPErrorException if there was an XMPP error returned.
740     * @throws NotConnectedException if the XMPP connection is not connected.
741     * @throws InterruptedException if the calling thread was interrupted.
742     * @since 4.2.2
743     */
744    public List<DiscoverInfo> findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
745                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
746        DomainBareJid serviceName = connection().getXMPPServiceDomain();
747        return findServicesDiscoverInfo(serviceName, feature, stopOnFirst, useCache, encounteredExceptions);
748    }
749
750    /**
751     * Find all services under a given service that provide a given feature.
752     *
753     * @param serviceName the service to query
754     * @param feature the feature to search for
755     * @param stopOnFirst if true, stop searching after the first service was found
756     * @param useCache if true, query a cache first to avoid network I/O
757     * @param encounteredExceptions an optional map which will be filled with the exceptions encountered
758     * @return a possible empty list of services providing the given feature
759     * @throws NoResponseException if there was no response from the remote entity.
760     * @throws XMPPErrorException if there was an XMPP error returned.
761     * @throws NotConnectedException if the XMPP connection is not connected.
762     * @throws InterruptedException if the calling thread was interrupted.
763     * @since 4.3.0
764     */
765    public List<DiscoverInfo> findServicesDiscoverInfo(DomainBareJid serviceName, String feature, boolean stopOnFirst,
766                    boolean useCache, Map<? super Jid, Exception> encounteredExceptions)
767            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
768        List<DiscoverInfo> serviceDiscoInfo;
769        if (useCache) {
770            serviceDiscoInfo = services.lookup(feature);
771            if (serviceDiscoInfo != null) {
772                return serviceDiscoInfo;
773            }
774        }
775        serviceDiscoInfo = new LinkedList<>();
776        // Send the disco packet to the server itself
777        DiscoverInfo info;
778        try {
779            info = discoverInfo(serviceName);
780        } catch (XMPPErrorException e) {
781            if (encounteredExceptions != null) {
782                encounteredExceptions.put(serviceName, e);
783            }
784            return serviceDiscoInfo;
785        }
786        // Check if the server supports the feature
787        if (info.containsFeature(feature)) {
788            serviceDiscoInfo.add(info);
789            if (stopOnFirst) {
790                if (useCache) {
791                    // Cache the discovered information
792                    services.put(feature, serviceDiscoInfo);
793                }
794                return serviceDiscoInfo;
795            }
796        }
797        DiscoverItems items;
798        try {
799            // Get the disco items and send the disco packet to each server item
800            items = discoverItems(serviceName);
801        } catch (XMPPErrorException e) {
802            if (encounteredExceptions != null) {
803                encounteredExceptions.put(serviceName, e);
804            }
805            return serviceDiscoInfo;
806        }
807        for (DiscoverItems.Item item : items.getItems()) {
808            Jid address = item.getEntityID();
809            try {
810                // TODO is it OK here in all cases to query without the node attribute?
811                // MultipleRecipientManager queried initially also with the node attribute, but this
812                // could be simply a fault instead of intentional.
813                info = discoverInfo(address);
814            }
815            catch (XMPPErrorException | NoResponseException e) {
816                if (encounteredExceptions != null) {
817                    encounteredExceptions.put(address, e);
818                }
819                continue;
820            }
821            if (info.containsFeature(feature)) {
822                serviceDiscoInfo.add(info);
823                if (stopOnFirst) {
824                    break;
825                }
826            }
827        }
828        if (useCache) {
829            // Cache the discovered information
830            services.put(feature, serviceDiscoInfo);
831        }
832        return serviceDiscoInfo;
833    }
834
835    /**
836     * Find all services under the users service that provide a given feature.
837     *
838     * @param feature the feature to search for
839     * @param stopOnFirst if true, stop searching after the first service was found
840     * @param useCache if true, query a cache first to avoid network I/O
841     * @return a possible empty list of services providing the given feature
842     * @throws NoResponseException if there was no response from the remote entity.
843     * @throws XMPPErrorException if there was an XMPP error returned.
844     * @throws NotConnectedException if the XMPP connection is not connected.
845     * @throws InterruptedException if the calling thread was interrupted.
846     */
847    public List<DomainBareJid> findServices(String feature, boolean stopOnFirst, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
848        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, stopOnFirst, useCache);
849        List<DomainBareJid> res = new ArrayList<>(services.size());
850        for (DiscoverInfo info : services) {
851            res.add(info.getFrom().asDomainBareJid());
852        }
853        return res;
854    }
855
856    public DomainBareJid findService(String feature, boolean useCache, String category, String type)
857                    throws NoResponseException, XMPPErrorException, NotConnectedException,
858                    InterruptedException {
859        boolean noCategory = StringUtils.isNullOrEmpty(category);
860        boolean noType = StringUtils.isNullOrEmpty(type);
861        if (noType != noCategory) {
862            throw new IllegalArgumentException("Must specify either both, category and type, or none");
863        }
864
865        List<DiscoverInfo> services = findServicesDiscoverInfo(feature, false, useCache);
866        if (services.isEmpty()) {
867            return null;
868        }
869
870        if (!noCategory && !noType) {
871            for (DiscoverInfo info : services) {
872                if (info.hasIdentity(category, type)) {
873                    return info.getFrom().asDomainBareJid();
874                }
875            }
876        }
877
878        return services.get(0).getFrom().asDomainBareJid();
879    }
880
881    public DomainBareJid findService(String feature, boolean useCache) throws NoResponseException,
882                    XMPPErrorException, NotConnectedException, InterruptedException {
883        return findService(feature, useCache, null, null);
884    }
885
886    public boolean addEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
887        return entityCapabilitiesChangedListeners.add(entityCapabilitiesChangedListener);
888    }
889
890    public boolean removeEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) {
891        return entityCapabilitiesChangedListeners.remove(entityCapabilitiesChangedListener);
892    }
893
894    private static final int RENEW_ENTITY_CAPS_DELAY_MILLIS = 25;
895
896    private ScheduledAction renewEntityCapsScheduledAction;
897
898    private final AtomicInteger renewEntityCapsPerformed = new AtomicInteger();
899    private int renewEntityCapsRequested = 0;
900    private int scheduledRenewEntityCapsAvoided = 0;
901
902    /**
903     * Notify the {@link EntityCapabilitiesChangedListener} about changed capabilities.
904     */
905    private synchronized void renewEntityCapsVersion() {
906        if (entityCapabilitiesChangedListeners.isEmpty()) {
907            return;
908        }
909
910        renewEntityCapsRequested++;
911        if (renewEntityCapsScheduledAction != null) {
912            boolean canceled = renewEntityCapsScheduledAction.cancel();
913            if (canceled) {
914                scheduledRenewEntityCapsAvoided++;
915            }
916        }
917
918        renewEntityCapsScheduledAction = scheduleBlocking(() -> {
919            final XMPPConnection connection = connection();
920            if (connection == null) {
921                return;
922            }
923
924            renewEntityCapsPerformed.incrementAndGet();
925
926            DiscoverInfoBuilder discoverInfoBuilder = DiscoverInfo.builder("synthetized-disco-info-response")
927                            .ofType(IQ.Type.result);
928            addDiscoverInfoTo(discoverInfoBuilder);
929            DiscoverInfo synthesizedDiscoveryInfo = discoverInfoBuilder.build();
930
931            for (EntityCapabilitiesChangedListener entityCapabilitiesChangedListener : entityCapabilitiesChangedListeners) {
932                entityCapabilitiesChangedListener.onEntityCapabilitiesChanged(synthesizedDiscoveryInfo);
933            }
934
935            // Re-send the last sent presence, and let the stanza interceptor
936            // add a <c/> node to it.
937            // See http://xmpp.org/extensions/xep-0115.html#advertise
938            // We only send a presence packet if there was already one send
939            // to respect ConnectionConfiguration.isSendPresence()
940            final Presence presenceSend = this.presenceSend;
941            if (connection.isAuthenticated() && presenceSend != null) {
942                Presence presence = presenceSend.asBuilder(connection).build();
943                try {
944                    connection.sendStanza(presence);
945                }
946                catch (InterruptedException | NotConnectedException e) {
947                    LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e);
948                }
949            }
950        }, RENEW_ENTITY_CAPS_DELAY_MILLIS, TimeUnit.MILLISECONDS);
951    }
952
953    public static void addDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
954        synchronized (discoInfoLookupShortcutMechanisms) {
955            discoInfoLookupShortcutMechanisms.add(discoInfoLookupShortcutMechanism);
956            Collections.sort(discoInfoLookupShortcutMechanisms);
957        }
958    }
959
960    public static void removeDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) {
961        synchronized (discoInfoLookupShortcutMechanisms) {
962            discoInfoLookupShortcutMechanisms.remove(discoInfoLookupShortcutMechanism);
963        }
964    }
965
966    public synchronized Stats getStats() {
967        return new Stats(this);
968    }
969
970    public static final class Stats extends AbstractStats {
971
972        public final int renewEntityCapsRequested;
973        public final int renewEntityCapsPerformed;
974        public final int scheduledRenewEntityCapsAvoided;
975
976        private Stats(ServiceDiscoveryManager serviceDiscoveryManager) {
977            renewEntityCapsRequested = serviceDiscoveryManager.renewEntityCapsRequested;
978            renewEntityCapsPerformed = serviceDiscoveryManager.renewEntityCapsPerformed.get();
979            scheduledRenewEntityCapsAvoided = serviceDiscoveryManager.scheduledRenewEntityCapsAvoided;
980        }
981
982        @Override
983        public void appendStatsTo(ExtendedAppendable appendable) throws IOException {
984            StringUtils.appendHeading(appendable, "ServiceDiscoveryManager stats", '#').append('\n');
985            appendable.append("renew-entitycaps-requested: ").append(renewEntityCapsRequested).append('\n');
986            appendable.append("renew-entitycaps-performed: ").append(renewEntityCapsPerformed).append('\n');
987            appendable.append("scheduled-renew-entitycaps-avoided: ").append(scheduledRenewEntityCapsAvoided).append('\n');
988        }
989
990    }
991}