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