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