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