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