/** * * Copyright 2003-2007 Jive Software, 2018-2020 Florian Schmaus. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.jivesoftware.smackx.disco; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; import java.util.logging.Logger; import org.jivesoftware.smack.ConnectionCreationListener; import org.jivesoftware.smack.ConnectionListener; import org.jivesoftware.smack.Manager; import org.jivesoftware.smack.ScheduledAction; import org.jivesoftware.smack.SmackException.NoResponseException; import org.jivesoftware.smack.SmackException.NotConnectedException; import org.jivesoftware.smack.XMPPConnection; import org.jivesoftware.smack.XMPPConnectionRegistry; import org.jivesoftware.smack.XMPPException.XMPPErrorException; import org.jivesoftware.smack.filter.PresenceTypeFilter; import org.jivesoftware.smack.internal.AbstractStats; import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler; import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode; import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.Stanza; import org.jivesoftware.smack.packet.StanzaError; import org.jivesoftware.smack.util.CollectionUtil; import org.jivesoftware.smack.util.ExtendedAppendable; import org.jivesoftware.smack.util.Objects; import org.jivesoftware.smack.util.StringUtils; import org.jivesoftware.smackx.disco.packet.DiscoverInfo; import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity; import org.jivesoftware.smackx.disco.packet.DiscoverInfoBuilder; import org.jivesoftware.smackx.disco.packet.DiscoverItems; import org.jivesoftware.smackx.xdata.packet.DataForm; import org.jxmpp.jid.DomainBareJid; import org.jxmpp.jid.EntityBareJid; import org.jxmpp.jid.Jid; import org.jxmpp.util.cache.Cache; import org.jxmpp.util.cache.ExpirationCache; /** * Manages discovery of services in XMPP entities. This class provides: *
    *
  1. A registry of supported features in this XMPP entity. *
  2. Automatic response when this XMPP entity is queried for information. *
  3. Ability to discover items and information of remote XMPP entities. *
  4. Ability to publish publicly available items. *
* * @author Gaston Dombiak * @author Florian Schmaus */ public final class ServiceDiscoveryManager extends Manager { private static final Logger LOGGER = Logger.getLogger(ServiceDiscoveryManager.class.getName()); private static final String DEFAULT_IDENTITY_NAME = "Smack"; private static final String DEFAULT_IDENTITY_CATEGORY = "client"; private static final String DEFAULT_IDENTITY_TYPE = "pc"; private static final List discoInfoLookupShortcutMechanisms = new ArrayList<>(2); private static DiscoverInfo.Identity defaultIdentity = new Identity(DEFAULT_IDENTITY_CATEGORY, DEFAULT_IDENTITY_NAME, DEFAULT_IDENTITY_TYPE); private final Set identities = new HashSet<>(); private DiscoverInfo.Identity identity = defaultIdentity; private final Set entityCapabilitiesChangedListeners = new CopyOnWriteArraySet<>(); private static final Map instances = new WeakHashMap<>(); private final Set features = new HashSet<>(); private List extendedInfos = new ArrayList<>(2); private final Map nodeInformationProviders = new ConcurrentHashMap<>(); private volatile Presence presenceSend; // Create a new ServiceDiscoveryManager on every established connection static { XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { @Override public void connectionCreated(XMPPConnection connection) { getInstanceFor(connection); } }); } /** * Set the default identity all new connections will have. If unchanged the default identity is an * identity where category is set to 'client', type is set to 'pc' and name is set to 'Smack'. * * @param identity TODO javadoc me please */ public static void setDefaultIdentity(DiscoverInfo.Identity identity) { defaultIdentity = identity; } /** * Creates a new ServiceDiscoveryManager for a given XMPPConnection. This means that the * service manager will respond to any service discovery request that the connection may * receive. * * @param connection the connection to which a ServiceDiscoveryManager is going to be created. */ private ServiceDiscoveryManager(XMPPConnection connection) { super(connection); addFeature(DiscoverInfo.NAMESPACE); addFeature(DiscoverItems.NAMESPACE); // Listen for disco#items requests and answer with an empty result connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverItems.ELEMENT, DiscoverItems.NAMESPACE, IQ.Type.get, Mode.async) { @Override public IQ handleIQRequest(IQ iqRequest) { DiscoverItems discoverItems = (DiscoverItems) iqRequest; DiscoverItems response = new DiscoverItems(); response.setType(IQ.Type.result); response.setTo(discoverItems.getFrom()); response.setStanzaId(discoverItems.getStanzaId()); response.setNode(discoverItems.getNode()); // Add the defined items related to the requested node. Look for // the NodeInformationProvider associated with the requested node. NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverItems.getNode()); if (nodeInformationProvider != null) { // Specified node was found, add node items response.addItems(nodeInformationProvider.getNodeItems()); // Add packet extensions response.addExtensions(nodeInformationProvider.getNodePacketExtensions()); } else if (discoverItems.getNode() != null) { // Return error since client doesn't contain // the specified node response.setType(IQ.Type.error); response.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build()); } return response; } }); // Listen for disco#info requests and answer the client's supported features // To add a new feature as supported use the #addFeature message connection.registerIQRequestHandler(new AbstractIqRequestHandler(DiscoverInfo.ELEMENT, DiscoverInfo.NAMESPACE, IQ.Type.get, Mode.async) { @Override public IQ handleIQRequest(IQ iqRequest) { DiscoverInfo discoverInfo = (DiscoverInfo) iqRequest; // Answer the client's supported features if the request is of the GET type DiscoverInfoBuilder responseBuilder = DiscoverInfoBuilder.buildResponseFor(discoverInfo, IQ.ResponseType.result); // Add the client's identity and features only if "node" is null // and if the request was not send to a node. If Entity Caps are // enabled the client's identity and features are may also added // if the right node is chosen if (discoverInfo.getNode() == null) { addDiscoverInfoTo(responseBuilder); } else { // Disco#info was sent to a node. Check if we have information of the // specified node NodeInformationProvider nodeInformationProvider = getNodeInformationProvider(discoverInfo.getNode()); if (nodeInformationProvider != null) { // Node was found. Add node features responseBuilder.addFeatures(nodeInformationProvider.getNodeFeatures()); // Add node identities responseBuilder.addIdentities(nodeInformationProvider.getNodeIdentities()); // Add packet extensions responseBuilder.addOptExtensions(nodeInformationProvider.getNodePacketExtensions()); } else { // Return error since specified node was not found responseBuilder.ofType(IQ.Type.error); responseBuilder.setError(StanzaError.getBuilder(StanzaError.Condition.item_not_found).build()); } } DiscoverInfo response = responseBuilder.build(); return response; } }); connection.addConnectionListener(new ConnectionListener() { @Override public void authenticated(XMPPConnection connection, boolean resumed) { // Reset presenceSend when the connection was not resumed if (!resumed) { presenceSend = null; } } }); connection.addStanzaSendingListener(p -> presenceSend = (Presence) p, PresenceTypeFilter.OUTGOING_PRESENCE_BROADCAST); } /** * Returns the name of the client that will be returned when asked for the client identity * in a disco request. The name could be any value you need to identity this client. * * @return the name of the client that will be returned when asked for the client identity * in a disco request. */ public String getIdentityName() { return identity.getName(); } /** * Sets the default identity the client will report. * * @param identity TODO javadoc me please */ public synchronized void setIdentity(Identity identity) { this.identity = Objects.requireNonNull(identity, "Identity can not be null"); // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); } /** * Return the default identity of the client. * * @return the default identity. */ public Identity getIdentity() { return identity; } /** * Returns the type of client that will be returned when asked for the client identity in a * disco request. The valid types are defined by the category client. Follow this link to learn * the possible types: XMPP Registry for Service Discovery Identities * * @return the type of client that will be returned when asked for the client identity in a * disco request. */ public String getIdentityType() { return identity.getType(); } /** * Add an further identity to the client. * * @param identity TODO javadoc me please */ public synchronized void addIdentity(DiscoverInfo.Identity identity) { identities.add(identity); // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); } /** * Remove an identity from the client. Note that the client needs at least one identity, the default identity, which * can not be removed. * * @param identity TODO javadoc me please * @return true, if successful. Otherwise the default identity was given. */ public synchronized boolean removeIdentity(DiscoverInfo.Identity identity) { if (identity.equals(this.identity)) return false; identities.remove(identity); // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); return true; } /** * Returns all identities of this client as unmodifiable Collection. * * @return all identies as set */ public Set getIdentities() { Set res = new HashSet<>(identities); // Add the main identity that must exist res.add(identity); return Collections.unmodifiableSet(res); } /** * Returns the ServiceDiscoveryManager instance associated with a given XMPPConnection. * * @param connection the connection used to look for the proper ServiceDiscoveryManager. * @return the ServiceDiscoveryManager associated with a given XMPPConnection. */ public static synchronized ServiceDiscoveryManager getInstanceFor(XMPPConnection connection) { ServiceDiscoveryManager sdm = instances.get(connection); if (sdm == null) { sdm = new ServiceDiscoveryManager(connection); // Register the new instance and associate it with the connection instances.put(connection, sdm); } return sdm; } /** * Add discover info response data. * * @see XEP-30 Basic Protocol; Example 2 * * @param response the discover info response packet */ public synchronized void addDiscoverInfoTo(DiscoverInfoBuilder response) { // First add the identities of the connection response.addIdentities(getIdentities()); // Add the registered features to the response for (String feature : getFeatures()) { response.addFeature(feature); } response.addExtensions(extendedInfos); } /** * Returns the NodeInformationProvider responsible for providing information * (ie items) related to a given node or null if none.

* * In MUC, a node could be 'http://jabber.org/protocol/muc#rooms' which means that the * NodeInformationProvider will provide information about the rooms where the user has joined. * * @param node the node that contains items associated with an entity not addressable as a JID. * @return the NodeInformationProvider responsible for providing information related * to a given node. */ private NodeInformationProvider getNodeInformationProvider(String node) { if (node == null) { return null; } return nodeInformationProviders.get(node); } /** * Sets the NodeInformationProvider responsible for providing information * (ie items) related to a given node. Every time this client receives a disco request * regarding the items of a given node, the provider associated to that node will be the * responsible for providing the requested information.

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

* The result is a copied modifiable list of the original features. *

* * @return a List of the supported features by this XMPP entity. */ public synchronized List getFeatures() { return new ArrayList<>(features); } /** * Registers that a new feature is supported by this XMPP entity. When this client is * queried for its information the registered features will be answered.

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

* * Since no stanza is actually sent to the server it is safe to perform this operation * before logging to the server. * * @param feature the feature to remove from the supported features. */ public synchronized void removeFeature(String feature) { features.remove(feature); // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); } /** * Returns true if the specified feature is registered in the ServiceDiscoveryManager. * * @param feature the feature to look for. * @return a boolean indicating if the specified featured is registered or not. */ public synchronized boolean includesFeature(String feature) { return features.contains(feature); } /** * Registers extended discovery information of this XMPP entity. When this * client is queried for its information this data form will be returned as * specified by XEP-0128. *

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

* * Since no stanza is actually sent to the server it is safe to perform this * operation before logging to the server. In fact, you may want to * configure the extended info before logging to the server so that the * information is already available if it is required upon login. * * @param extendedInfo the data form that contains the extend service discovery information. * @return the old data form which got replaced (if any) * @since 4.4.0 */ public DataForm addExtendedInfo(DataForm extendedInfo) { String formType = extendedInfo.getFormType(); StringUtils.requireNotNullNorEmpty(formType, "The data form must have a form type set"); DataForm removedDataForm; synchronized (this) { removedDataForm = DataForm.remove(extendedInfos, formType); extendedInfos.add(extendedInfo); // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); } return removedDataForm; } /** * Remove the extended discovery information of the given form type. * * @param formType the type of the data form with the extended discovery information to remove. * @since 4.4.0 */ public synchronized void removeExtendedInfo(String formType) { DataForm removedForm = DataForm.remove(extendedInfos, formType); if (removedForm != null) { renewEntityCapsVersion(); } } /** * Returns the data form as List of PacketExtensions, or null if no data form is set. * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider) * * @return the data form as List of PacketExtensions */ public synchronized List getExtendedInfo() { return CollectionUtil.newListWith(extendedInfos); } /** * Returns the data form as List of PacketExtensions, or null if no data form is set. * This representation is needed by some classes (e.g. EntityCapsManager, NodeInformationProvider) * * @return the data form as List of PacketExtensions * @deprecated use {@link #getExtendedInfo()} instead. */ // TODO: Remove in Smack 4.5 @Deprecated public List getExtendedInfoAsList() { return getExtendedInfo(); } /** * Removes the data form containing extended service discovery information * from the information returned by this XMPP entity.

* * Since no stanza is actually sent to the server it is safe to perform this * operation before logging to the server. */ public synchronized void removeExtendedInfo() { int extendedInfosCount = extendedInfos.size(); extendedInfos.clear(); if (extendedInfosCount > 0) { // Notify others of a state change of SDM. In order to keep the state consistent, this // method is synchronized renewEntityCapsVersion(); } } /** * Returns the discovered information of a given XMPP entity addressed by its JID. * Use null as entityID to query the server * * @param entityID the address of the XMPP entity or null. * @return the discovered information. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NoResponseException if there was no response from the remote entity. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public DiscoverInfo discoverInfo(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { if (entityID == null) return discoverInfo(null, null); synchronized (discoInfoLookupShortcutMechanisms) { for (DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism : discoInfoLookupShortcutMechanisms) { DiscoverInfo info = discoInfoLookupShortcutMechanism.getDiscoverInfoByUser(this, entityID); if (info != null) { // We were able to retrieve the information from Entity Caps and // avoided a disco request, hurray! return info; } } } // Last resort: Standard discovery. return discoverInfo(entityID, null); } /** * Returns the discovered information of a given XMPP entity addressed by its JID and * note attribute. Use this message only when trying to query information which is not * directly addressable. * * @see XEP-30 Basic Protocol * @see XEP-30 Info Nodes * * @param entityID the address of the XMPP entity. * @param node the optional attribute that supplements the 'jid' attribute. * @return the discovered information. * @throws XMPPErrorException if the operation failed for some reason. * @throws NoResponseException if there was no response from the server. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public DiscoverInfo discoverInfo(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { XMPPConnection connection = connection(); // Discover the entity's info DiscoverInfo discoInfoRequest = DiscoverInfo.builder(connection) .to(entityID) .setNode(node) .build(); Stanza result = connection.createStanzaCollectorAndSend(discoInfoRequest).nextResultOrThrow(); return (DiscoverInfo) result; } /** * Returns the discovered items of a given XMPP entity addressed by its JID. * * @param entityID the address of the XMPP entity. * @return the discovered information. * @throws XMPPErrorException if the operation failed for some reason. * @throws NoResponseException if there was no response from the server. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public DiscoverItems discoverItems(Jid entityID) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return discoverItems(entityID, null); } /** * Returns the discovered items of a given XMPP entity addressed by its JID and * note attribute. Use this message only when trying to query information which is not * directly addressable. * * @param entityID the address of the XMPP entity. * @param node the optional attribute that supplements the 'jid' attribute. * @return the discovered items. * @throws XMPPErrorException if the operation failed for some reason. * @throws NoResponseException if there was no response from the server. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public DiscoverItems discoverItems(Jid entityID, String node) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { // Discover the entity's items DiscoverItems disco = new DiscoverItems(); disco.setType(IQ.Type.get); disco.setTo(entityID); disco.setNode(node); Stanza result = connection().createStanzaCollectorAndSend(disco).nextResultOrThrow(); return (DiscoverItems) result; } /** * Returns true if the server supports the given feature. * * @param feature TODO javadoc me please * @return true if the server supports the given feature. * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. * @since 4.1 */ public boolean serverSupportsFeature(CharSequence feature) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return serverSupportsFeatures(feature); } public boolean serverSupportsFeatures(CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return serverSupportsFeatures(Arrays.asList(features)); } public boolean serverSupportsFeatures(Collection features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return supportsFeatures(connection().getXMPPServiceDomain(), features); } /** * Check if the given features are supported by the connection account. This means that the discovery information * lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager. * * @param features the features to check * @return true if all features are supported by the connection account, false otherwise * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. * @since 4.2.2 */ public boolean accountSupportsFeatures(CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return accountSupportsFeatures(Arrays.asList(features)); } /** * Check if the given collection of features are supported by the connection account. This means that the discovery * information lookup will be performed on the bare JID of the connection managed by this ServiceDiscoveryManager. * * @param features a collection of features * @return true if all features are supported by the connection account, false otherwise * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. * @since 4.2.2 */ public boolean accountSupportsFeatures(Collection features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { EntityBareJid accountJid = connection().getUser().asEntityBareJid(); return supportsFeatures(accountJid, features); } /** * Queries the remote entity for it's features and returns true if the given feature is found. * * @param jid the JID of the remote entity * @param feature TODO javadoc me please * @return true if the entity supports the feature, false otherwise * @throws XMPPErrorException if there was an XMPP error returned. * @throws NoResponseException if there was no response from the remote entity. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public boolean supportsFeature(Jid jid, CharSequence feature) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return supportsFeatures(jid, feature); } public boolean supportsFeatures(Jid jid, CharSequence... features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return supportsFeatures(jid, Arrays.asList(features)); } public boolean supportsFeatures(Jid jid, Collection features) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { DiscoverInfo result = discoverInfo(jid); for (CharSequence feature : features) { if (!result.containsFeature(feature)) { return false; } } return true; } /** * Create a cache to hold the 25 most recently lookup services for a given feature for a period * of 24 hours. */ private final Cache> services = new ExpirationCache<>(25, 24 * 60 * 60 * 1000); /** * Find all services under the users service that provide a given feature. * * @param feature the feature to search for * @param stopOnFirst if true, stop searching after the first service was found * @param useCache if true, query a cache first to avoid network I/O * @return a possible empty list of services providing the given feature * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public List findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return findServicesDiscoverInfo(feature, stopOnFirst, useCache, null); } /** * Find all services under the users service that provide a given feature. * * @param feature the feature to search for * @param stopOnFirst if true, stop searching after the first service was found * @param useCache if true, query a cache first to avoid network I/O * @param encounteredExceptions an optional map which will be filled with the exceptions encountered * @return a possible empty list of services providing the given feature * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. * @since 4.2.2 */ public List findServicesDiscoverInfo(String feature, boolean stopOnFirst, boolean useCache, Map encounteredExceptions) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { DomainBareJid serviceName = connection().getXMPPServiceDomain(); return findServicesDiscoverInfo(serviceName, feature, stopOnFirst, useCache, encounteredExceptions); } /** * Find all services under a given service that provide a given feature. * * @param serviceName the service to query * @param feature the feature to search for * @param stopOnFirst if true, stop searching after the first service was found * @param useCache if true, query a cache first to avoid network I/O * @param encounteredExceptions an optional map which will be filled with the exceptions encountered * @return a possible empty list of services providing the given feature * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. * @since 4.3.0 */ public List findServicesDiscoverInfo(DomainBareJid serviceName, String feature, boolean stopOnFirst, boolean useCache, Map encounteredExceptions) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { List serviceDiscoInfo; if (useCache) { serviceDiscoInfo = services.lookup(feature); if (serviceDiscoInfo != null) { return serviceDiscoInfo; } } serviceDiscoInfo = new LinkedList<>(); // Send the disco packet to the server itself DiscoverInfo info; try { info = discoverInfo(serviceName); } catch (XMPPErrorException e) { if (encounteredExceptions != null) { encounteredExceptions.put(serviceName, e); } return serviceDiscoInfo; } // Check if the server supports the feature if (info.containsFeature(feature)) { serviceDiscoInfo.add(info); if (stopOnFirst) { if (useCache) { // Cache the discovered information services.put(feature, serviceDiscoInfo); } return serviceDiscoInfo; } } DiscoverItems items; try { // Get the disco items and send the disco packet to each server item items = discoverItems(serviceName); } catch (XMPPErrorException e) { if (encounteredExceptions != null) { encounteredExceptions.put(serviceName, e); } return serviceDiscoInfo; } for (DiscoverItems.Item item : items.getItems()) { Jid address = item.getEntityID(); try { // TODO is it OK here in all cases to query without the node attribute? // MultipleRecipientManager queried initially also with the node attribute, but this // could be simply a fault instead of intentional. info = discoverInfo(address); } catch (XMPPErrorException | NoResponseException e) { if (encounteredExceptions != null) { encounteredExceptions.put(address, e); } continue; } if (info.containsFeature(feature)) { serviceDiscoInfo.add(info); if (stopOnFirst) { break; } } } if (useCache) { // Cache the discovered information services.put(feature, serviceDiscoInfo); } return serviceDiscoInfo; } /** * Find all services under the users service that provide a given feature. * * @param feature the feature to search for * @param stopOnFirst if true, stop searching after the first service was found * @param useCache if true, query a cache first to avoid network I/O * @return a possible empty list of services providing the given feature * @throws NoResponseException if there was no response from the remote entity. * @throws XMPPErrorException if there was an XMPP error returned. * @throws NotConnectedException if the XMPP connection is not connected. * @throws InterruptedException if the calling thread was interrupted. */ public List findServices(String feature, boolean stopOnFirst, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { List services = findServicesDiscoverInfo(feature, stopOnFirst, useCache); List res = new ArrayList<>(services.size()); for (DiscoverInfo info : services) { res.add(info.getFrom().asDomainBareJid()); } return res; } public DomainBareJid findService(String feature, boolean useCache, String category, String type) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { boolean noCategory = StringUtils.isNullOrEmpty(category); boolean noType = StringUtils.isNullOrEmpty(type); if (noType != noCategory) { throw new IllegalArgumentException("Must specify either both, category and type, or none"); } List services = findServicesDiscoverInfo(feature, false, useCache); if (services.isEmpty()) { return null; } if (!noCategory && !noType) { for (DiscoverInfo info : services) { if (info.hasIdentity(category, type)) { return info.getFrom().asDomainBareJid(); } } } return services.get(0).getFrom().asDomainBareJid(); } public DomainBareJid findService(String feature, boolean useCache) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { return findService(feature, useCache, null, null); } public boolean addEntityCapabilitiesChangedListener(EntityCapabilitiesChangedListener entityCapabilitiesChangedListener) { return entityCapabilitiesChangedListeners.add(entityCapabilitiesChangedListener); } private static final int RENEW_ENTITY_CAPS_DELAY_MILLIS = 25; private ScheduledAction renewEntityCapsScheduledAction; private final AtomicInteger renewEntityCapsPerformed = new AtomicInteger(); private int renewEntityCapsRequested = 0; private int scheduledRenewEntityCapsAvoided = 0; /** * Notify the {@link EntityCapabilitiesChangedListener} about changed capabilities. */ private synchronized void renewEntityCapsVersion() { renewEntityCapsRequested++; if (renewEntityCapsScheduledAction != null) { boolean canceled = renewEntityCapsScheduledAction.cancel(); if (canceled) { scheduledRenewEntityCapsAvoided++; } } renewEntityCapsScheduledAction = scheduleBlocking(() -> { final XMPPConnection connection = connection(); if (connection == null) { return; } renewEntityCapsPerformed.incrementAndGet(); DiscoverInfoBuilder discoverInfoBuilder = DiscoverInfo.builder("synthetized-disco-info-response") .ofType(IQ.Type.result); addDiscoverInfoTo(discoverInfoBuilder); DiscoverInfo synthesizedDiscoveryInfo = discoverInfoBuilder.build(); for (EntityCapabilitiesChangedListener entityCapabilitiesChangedListener : entityCapabilitiesChangedListeners) { entityCapabilitiesChangedListener.onEntityCapabilitiesChanged(synthesizedDiscoveryInfo); } // Re-send the last sent presence, and let the stanza interceptor // add a node to it. // See http://xmpp.org/extensions/xep-0115.html#advertise // We only send a presence packet if there was already one send // to respect ConnectionConfiguration.isSendPresence() final Presence presenceSend = this.presenceSend; if (connection.isAuthenticated() && presenceSend != null) { Presence presence = presenceSend.asBuilder(connection).build(); try { connection.sendStanza(presence); } catch (InterruptedException | NotConnectedException e) { LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e); } } }, RENEW_ENTITY_CAPS_DELAY_MILLIS, TimeUnit.MILLISECONDS); } public static void addDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) { synchronized (discoInfoLookupShortcutMechanisms) { discoInfoLookupShortcutMechanisms.add(discoInfoLookupShortcutMechanism); Collections.sort(discoInfoLookupShortcutMechanisms); } } public static void removeDiscoInfoLookupShortcutMechanism(DiscoInfoLookupShortcutMechanism discoInfoLookupShortcutMechanism) { synchronized (discoInfoLookupShortcutMechanisms) { discoInfoLookupShortcutMechanisms.remove(discoInfoLookupShortcutMechanism); } } public synchronized Stats getStats() { return new Stats(this); } public static final class Stats extends AbstractStats { public final int renewEntityCapsRequested; public final int renewEntityCapsPerformed; public final int scheduledRenewEntityCapsAvoided; private Stats(ServiceDiscoveryManager serviceDiscoveryManager) { renewEntityCapsRequested = serviceDiscoveryManager.renewEntityCapsRequested; renewEntityCapsPerformed = serviceDiscoveryManager.renewEntityCapsPerformed.get(); scheduledRenewEntityCapsAvoided = serviceDiscoveryManager.scheduledRenewEntityCapsAvoided; } @Override public void appendStatsTo(ExtendedAppendable appendable) throws IOException { StringUtils.appendHeading(appendable, "ServiceDiscoveryManager stats", '#').append('\n'); appendable.append("renew-entitycaps-requested: ").append(renewEntityCapsRequested).append('\n'); appendable.append("renew-entitycaps-performed: ").append(renewEntityCapsPerformed).append('\n'); appendable.append("scheduled-renew-entitycaps-avoided: ").append(scheduledRenewEntityCapsAvoided).append('\n'); } } }