001/** 002 * 003 * Copyright © 2009 Jonas Ådahl, 2011-2014 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.caps; 018 019import java.io.UnsupportedEncodingException; 020import java.security.MessageDigest; 021import java.security.NoSuchAlgorithmException; 022import java.util.Comparator; 023import java.util.HashMap; 024import java.util.LinkedList; 025import java.util.List; 026import java.util.Locale; 027import java.util.Map; 028import java.util.Queue; 029import java.util.SortedSet; 030import java.util.TreeSet; 031import java.util.WeakHashMap; 032import java.util.concurrent.ConcurrentLinkedQueue; 033import java.util.logging.Level; 034import java.util.logging.Logger; 035 036import org.jivesoftware.smack.AbstractConnectionListener; 037import org.jivesoftware.smack.ConnectionCreationListener; 038import org.jivesoftware.smack.Manager; 039import org.jivesoftware.smack.SmackException.NoResponseException; 040import org.jivesoftware.smack.SmackException.NotConnectedException; 041import org.jivesoftware.smack.StanzaListener; 042import org.jivesoftware.smack.XMPPConnection; 043import org.jivesoftware.smack.XMPPConnectionRegistry; 044import org.jivesoftware.smack.XMPPException.XMPPErrorException; 045import org.jivesoftware.smack.filter.AndFilter; 046import org.jivesoftware.smack.filter.PresenceTypeFilter; 047import org.jivesoftware.smack.filter.StanzaExtensionFilter; 048import org.jivesoftware.smack.filter.StanzaFilter; 049import org.jivesoftware.smack.filter.StanzaTypeFilter; 050import org.jivesoftware.smack.packet.ExtensionElement; 051import org.jivesoftware.smack.packet.IQ; 052import org.jivesoftware.smack.packet.Presence; 053import org.jivesoftware.smack.packet.Stanza; 054import org.jivesoftware.smack.roster.AbstractPresenceEventListener; 055import org.jivesoftware.smack.roster.Roster; 056import org.jivesoftware.smack.util.StringUtils; 057import org.jivesoftware.smack.util.stringencoder.Base64; 058 059import org.jivesoftware.smackx.caps.cache.EntityCapsPersistentCache; 060import org.jivesoftware.smackx.caps.packet.CapsExtension; 061import org.jivesoftware.smackx.disco.AbstractNodeInformationProvider; 062import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; 063import org.jivesoftware.smackx.disco.packet.DiscoverInfo; 064import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Feature; 065import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity; 066import org.jivesoftware.smackx.xdata.FormField; 067import org.jivesoftware.smackx.xdata.packet.DataForm; 068 069import org.jxmpp.jid.DomainBareJid; 070import org.jxmpp.jid.FullJid; 071import org.jxmpp.jid.Jid; 072import org.jxmpp.util.cache.LruCache; 073 074/** 075 * Keeps track of entity capabilities. 076 * 077 * @author Florian Schmaus 078 * @see <a href="http://www.xmpp.org/extensions/xep-0115.html">XEP-0115: Entity Capabilities</a> 079 */ 080public final class EntityCapsManager extends Manager { 081 private static final Logger LOGGER = Logger.getLogger(EntityCapsManager.class.getName()); 082 083 public static final String NAMESPACE = CapsExtension.NAMESPACE; 084 public static final String ELEMENT = CapsExtension.ELEMENT; 085 086 private static final Map<String, MessageDigest> SUPPORTED_HASHES = new HashMap<String, MessageDigest>(); 087 088 /** 089 * The default hash. Currently 'sha-1'. 090 */ 091 private static final String DEFAULT_HASH = StringUtils.SHA1; 092 093 private static String DEFAULT_ENTITY_NODE = "http://www.igniterealtime.org/projects/smack"; 094 095 protected static EntityCapsPersistentCache persistentCache; 096 097 private static boolean autoEnableEntityCaps = true; 098 099 private static final Map<XMPPConnection, EntityCapsManager> instances = new WeakHashMap<>(); 100 101 private static final StanzaFilter PRESENCES_WITH_CAPS = new AndFilter(new StanzaTypeFilter(Presence.class), new StanzaExtensionFilter( 102 ELEMENT, NAMESPACE)); 103 104 /** 105 * Map of "node + '#' + hash" to DiscoverInfo data 106 */ 107 static final LruCache<String, DiscoverInfo> CAPS_CACHE = new LruCache<>(1000); 108 109 /** 110 * Map of Full JID -> DiscoverInfo/null. In case of c2s connection the 111 * key is formed as user@server/resource (resource is required) In case of 112 * link-local connection the key is formed as user@host (no resource) In 113 * case of a server or component the key is formed as domain 114 */ 115 static final LruCache<Jid, NodeVerHash> JID_TO_NODEVER_CACHE = new LruCache<>(10000); 116 117 static { 118 XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { 119 @Override 120 public void connectionCreated(XMPPConnection connection) { 121 getInstanceFor(connection); 122 } 123 }); 124 125 try { 126 MessageDigest sha1MessageDigest = MessageDigest.getInstance(DEFAULT_HASH); 127 SUPPORTED_HASHES.put(DEFAULT_HASH, sha1MessageDigest); 128 } catch (NoSuchAlgorithmException e) { 129 // Ignore 130 } 131 } 132 133 /** 134 * Set the default entity node that will be used for new EntityCapsManagers. 135 * 136 * @param entityNode 137 */ 138 public static void setDefaultEntityNode(String entityNode) { 139 DEFAULT_ENTITY_NODE = entityNode; 140 } 141 142 /** 143 * Add DiscoverInfo to the database. 144 * 145 * @param nodeVer 146 * The node and verification String (e.g. 147 * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). 148 * @param info 149 * DiscoverInfo for the specified node. 150 */ 151 public static void addDiscoverInfoByNode(String nodeVer, DiscoverInfo info) { 152 CAPS_CACHE.put(nodeVer, info); 153 154 if (persistentCache != null) 155 persistentCache.addDiscoverInfoByNodePersistent(nodeVer, info); 156 } 157 158 /** 159 * Get the Node version (node#ver) of a JID. Returns a String or null if 160 * EntiyCapsManager does not have any information. 161 * 162 * @param jid 163 * the user (Full JID) 164 * @return the node version (node#ver) or null 165 */ 166 public static String getNodeVersionByJid(Jid jid) { 167 NodeVerHash nvh = JID_TO_NODEVER_CACHE.lookup(jid); 168 if (nvh != null) { 169 return nvh.nodeVer; 170 } else { 171 return null; 172 } 173 } 174 175 public static NodeVerHash getNodeVerHashByJid(Jid jid) { 176 return JID_TO_NODEVER_CACHE.lookup(jid); 177 } 178 179 /** 180 * Get the discover info given a user name. The discover info is returned if 181 * the user has a node#ver associated with it and the node#ver has a 182 * discover info associated with it. 183 * 184 * @param user 185 * user name (Full JID) 186 * @return the discovered info 187 */ 188 public static DiscoverInfo getDiscoverInfoByUser(Jid user) { 189 NodeVerHash nvh = JID_TO_NODEVER_CACHE.lookup(user); 190 if (nvh == null) 191 return null; 192 193 return getDiscoveryInfoByNodeVer(nvh.nodeVer); 194 } 195 196 /** 197 * Retrieve DiscoverInfo for a specific node. 198 * 199 * @param nodeVer 200 * The node name (e.g. 201 * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). 202 * @return The corresponding DiscoverInfo or null if none is known. 203 */ 204 public static DiscoverInfo getDiscoveryInfoByNodeVer(String nodeVer) { 205 DiscoverInfo info = CAPS_CACHE.lookup(nodeVer); 206 207 // If it was not in CAPS_CACHE, try to retrieve the information from persistentCache 208 if (info == null && persistentCache != null) { 209 info = persistentCache.lookup(nodeVer); 210 // Promote the information to CAPS_CACHE if one was found 211 if (info != null) { 212 CAPS_CACHE.put(nodeVer, info); 213 } 214 } 215 216 // If we were able to retrieve information from one of the caches, copy it before returning 217 if (info != null) 218 info = new DiscoverInfo(info); 219 220 return info; 221 } 222 223 /** 224 * Set the persistent cache implementation. 225 * 226 * @param cache 227 */ 228 public static void setPersistentCache(EntityCapsPersistentCache cache) { 229 persistentCache = cache; 230 } 231 232 /** 233 * Sets the maximum cache sizes. 234 * 235 * @param maxJidToNodeVerSize 236 * @param maxCapsCacheSize 237 */ 238 public static void setMaxsCacheSizes(int maxJidToNodeVerSize, int maxCapsCacheSize) { 239 JID_TO_NODEVER_CACHE.setMaxCacheSize(maxJidToNodeVerSize); 240 CAPS_CACHE.setMaxCacheSize(maxCapsCacheSize); 241 } 242 243 /** 244 * Clears the memory cache. 245 */ 246 public static void clearMemoryCache() { 247 JID_TO_NODEVER_CACHE.clear(); 248 CAPS_CACHE.clear(); 249 } 250 251 private static void addCapsExtensionInfo(Jid from, CapsExtension capsExtension) { 252 String capsExtensionHash = capsExtension.getHash(); 253 String hashInUppercase = capsExtensionHash.toUpperCase(Locale.US); 254 // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1" 255 if (!SUPPORTED_HASHES.containsKey(hashInUppercase)) 256 return; 257 String hash = capsExtensionHash.toLowerCase(Locale.US); 258 259 String node = capsExtension.getNode(); 260 String ver = capsExtension.getVer(); 261 262 JID_TO_NODEVER_CACHE.put(from, new NodeVerHash(node, ver, hash)); 263 } 264 265 private final Queue<CapsVersionAndHash> lastLocalCapsVersions = new ConcurrentLinkedQueue<>(); 266 267 private final ServiceDiscoveryManager sdm; 268 269 private boolean entityCapsEnabled; 270 private CapsVersionAndHash currentCapsVersion; 271 private volatile Presence presenceSend; 272 273 /** 274 * The entity node String used by this EntityCapsManager instance. 275 */ 276 private String entityNode = DEFAULT_ENTITY_NODE; 277 278 private EntityCapsManager(XMPPConnection connection) { 279 super(connection); 280 this.sdm = ServiceDiscoveryManager.getInstanceFor(connection); 281 instances.put(connection, this); 282 283 connection.addConnectionListener(new AbstractConnectionListener() { 284 @Override 285 public void connected(XMPPConnection connection) { 286 // It's not clear when a server would report the caps stream 287 // feature, so we try to process it after we are connected and 288 // once after we are authenticated. 289 processCapsStreamFeatureIfAvailable(connection); 290 } 291 @Override 292 public void authenticated(XMPPConnection connection, boolean resumed) { 293 // It's not clear when a server would report the caps stream 294 // feature, so we try to process it after we are connected and 295 // once after we are authenticated. 296 processCapsStreamFeatureIfAvailable(connection); 297 298 // Reset presenceSend when the connection was not resumed 299 if (!resumed) { 300 presenceSend = null; 301 } 302 } 303 private void processCapsStreamFeatureIfAvailable(XMPPConnection connection) { 304 CapsExtension capsExtension = connection.getFeature( 305 CapsExtension.ELEMENT, CapsExtension.NAMESPACE); 306 if (capsExtension == null) { 307 return; 308 } 309 DomainBareJid from = connection.getXMPPServiceDomain(); 310 addCapsExtensionInfo(from, capsExtension); 311 } 312 }); 313 314 // This calculates the local entity caps version 315 updateLocalEntityCaps(); 316 317 if (autoEnableEntityCaps) 318 enableEntityCaps(); 319 320 connection.addAsyncStanzaListener(new StanzaListener() { 321 // Listen for remote presence stanzas with the caps extension 322 // If we receive such a stanza, record the JID and nodeVer 323 @Override 324 public void processStanza(Stanza packet) { 325 if (!entityCapsEnabled()) 326 return; 327 328 CapsExtension capsExtension = CapsExtension.from(packet); 329 Jid from = packet.getFrom(); 330 addCapsExtensionInfo(from, capsExtension); 331 } 332 333 }, PRESENCES_WITH_CAPS); 334 335 Roster.getInstanceFor(connection).addPresenceEventListener(new AbstractPresenceEventListener() { 336 @Override 337 public void presenceUnavailable(FullJid from, Presence presence) { 338 JID_TO_NODEVER_CACHE.remove(from); 339 } 340 }); 341 342 connection.addPacketSendingListener(new StanzaListener() { 343 @Override 344 public void processStanza(Stanza packet) { 345 presenceSend = (Presence) packet; 346 } 347 }, PresenceTypeFilter.OUTGOING_PRESENCE_BROADCAST); 348 349 // Intercept presence packages and add caps data when intended. 350 // XEP-0115 specifies that a client SHOULD include entity capabilities 351 // with every presence notification it sends. 352 StanzaListener packetInterceptor = new StanzaListener() { 353 @Override 354 public void processStanza(Stanza packet) { 355 if (!entityCapsEnabled) { 356 // Be sure to not send stanzas with the caps extension if it's not enabled 357 packet.removeExtension(CapsExtension.ELEMENT, CapsExtension.NAMESPACE); 358 return; 359 } 360 CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash(); 361 CapsExtension caps = new CapsExtension(entityNode, capsVersionAndHash.version, capsVersionAndHash.hash); 362 packet.overrideExtension(caps); 363 } 364 }; 365 connection.addPacketInterceptor(packetInterceptor, PresenceTypeFilter.AVAILABLE); 366 // It's important to do this as last action. Since it changes the 367 // behavior of the SDM in some ways 368 sdm.setEntityCapsManager(this); 369 } 370 371 public static synchronized EntityCapsManager getInstanceFor(XMPPConnection connection) { 372 if (SUPPORTED_HASHES.size() <= 0) 373 throw new IllegalStateException("No supported hashes for EntityCapsManager"); 374 375 EntityCapsManager entityCapsManager = instances.get(connection); 376 377 if (entityCapsManager == null) { 378 entityCapsManager = new EntityCapsManager(connection); 379 } 380 381 return entityCapsManager; 382 } 383 384 public synchronized void enableEntityCaps() { 385 // Add Entity Capabilities (XEP-0115) feature node. 386 sdm.addFeature(NAMESPACE); 387 updateLocalEntityCaps(); 388 entityCapsEnabled = true; 389 } 390 391 public synchronized void disableEntityCaps() { 392 entityCapsEnabled = false; 393 sdm.removeFeature(NAMESPACE); 394 } 395 396 public boolean entityCapsEnabled() { 397 return entityCapsEnabled; 398 } 399 400 public void setEntityNode(String entityNode) { 401 this.entityNode = entityNode; 402 updateLocalEntityCaps(); 403 } 404 405 /** 406 * Remove a record telling what entity caps node a user has. 407 * 408 * @param user 409 * the user (Full JID) 410 */ 411 // TODO: Change parameter type to Jid in Smack 4.3. 412 @SuppressWarnings("CollectionIncompatibleType") 413 public static void removeUserCapsNode(String user) { 414 // While JID_TO_NODEVER_CHACHE has the generic types <Jid, NodeVerHash>, it is ok to call remove with String 415 // arguments, since the same Jid and String representations would be equal and have the same hash code. 416 JID_TO_NODEVER_CACHE.remove(user); 417 } 418 419 /** 420 * Get our own caps version. The version depends on the enabled features. 421 * A caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI=' 422 * 423 * @return our own caps version 424 */ 425 public CapsVersionAndHash getCapsVersionAndHash() { 426 return currentCapsVersion; 427 } 428 429 /** 430 * Returns the local entity's NodeVer (e.g. 431 * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI= 432 * ) 433 * 434 * @return the local NodeVer 435 */ 436 public String getLocalNodeVer() { 437 CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash(); 438 if (capsVersionAndHash == null) { 439 return null; 440 } 441 return entityNode + '#' + capsVersionAndHash.version; 442 } 443 444 /** 445 * Returns true if Entity Caps are supported by a given JID. 446 * 447 * @param jid 448 * @return true if the entity supports Entity Capabilities. 449 * @throws XMPPErrorException 450 * @throws NoResponseException 451 * @throws NotConnectedException 452 * @throws InterruptedException 453 */ 454 public boolean areEntityCapsSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 455 return sdm.supportsFeature(jid, NAMESPACE); 456 } 457 458 /** 459 * Returns true if Entity Caps are supported by the local service/server. 460 * 461 * @return true if the user's server supports Entity Capabilities. 462 * @throws XMPPErrorException 463 * @throws NoResponseException 464 * @throws NotConnectedException 465 * @throws InterruptedException 466 */ 467 public boolean areEntityCapsSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 468 return areEntityCapsSupported(connection().getXMPPServiceDomain()); 469 } 470 471 /** 472 * Updates the local user Entity Caps information with the data provided 473 * 474 * If we are connected and there was already a presence send, another 475 * presence is send to inform others about your new Entity Caps node string. 476 * 477 */ 478 public void updateLocalEntityCaps() { 479 XMPPConnection connection = connection(); 480 481 DiscoverInfo discoverInfo = new DiscoverInfo(); 482 discoverInfo.setType(IQ.Type.result); 483 sdm.addDiscoverInfoTo(discoverInfo); 484 485 // getLocalNodeVer() will return a result only after currentCapsVersion is set. Therefore 486 // set it first and then call getLocalNodeVer() 487 currentCapsVersion = generateVerificationString(discoverInfo); 488 final String localNodeVer = getLocalNodeVer(); 489 discoverInfo.setNode(localNodeVer); 490 addDiscoverInfoByNode(localNodeVer, discoverInfo); 491 if (lastLocalCapsVersions.size() > 10) { 492 CapsVersionAndHash oldCapsVersion = lastLocalCapsVersions.poll(); 493 sdm.removeNodeInformationProvider(entityNode + '#' + oldCapsVersion.version); 494 } 495 lastLocalCapsVersions.add(currentCapsVersion); 496 497 if (connection != null) 498 JID_TO_NODEVER_CACHE.put(connection.getUser(), new NodeVerHash(entityNode, currentCapsVersion)); 499 500 final List<Identity> identities = new LinkedList<>(ServiceDiscoveryManager.getInstanceFor(connection).getIdentities()); 501 sdm.setNodeInformationProvider(localNodeVer, new AbstractNodeInformationProvider() { 502 List<String> features = sdm.getFeatures(); 503 List<ExtensionElement> packetExtensions = sdm.getExtendedInfoAsList(); 504 @Override 505 public List<String> getNodeFeatures() { 506 return features; 507 } 508 @Override 509 public List<Identity> getNodeIdentities() { 510 return identities; 511 } 512 @Override 513 public List<ExtensionElement> getNodePacketExtensions() { 514 return packetExtensions; 515 } 516 }); 517 518 // Re-send the last sent presence, and let the stanza interceptor 519 // add a <c/> node to it. 520 // See http://xmpp.org/extensions/xep-0115.html#advertise 521 // We only send a presence packet if there was already one send 522 // to respect ConnectionConfiguration.isSendPresence() 523 if (connection != null && connection.isAuthenticated() && presenceSend != null) { 524 try { 525 connection.sendStanza(presenceSend.cloneWithNewId()); 526 } 527 catch (InterruptedException | NotConnectedException e) { 528 LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e); 529 } 530 } 531 } 532 533 /** 534 * Verify DiscoverInfo and Caps Node as defined in XEP-0115 5.4 Processing 535 * Method. 536 * 537 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115 538 * 5.4 Processing Method</a> 539 * 540 * @param ver 541 * @param hash 542 * @param info 543 * @return true if it's valid and should be cache, false if not 544 */ 545 public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) { 546 // step 3.3 check for duplicate identities 547 if (info.containsDuplicateIdentities()) 548 return false; 549 550 // step 3.4 check for duplicate features 551 if (info.containsDuplicateFeatures()) 552 return false; 553 554 // step 3.5 check for well-formed packet extensions 555 if (verifyPacketExtensions(info)) 556 return false; 557 558 String calculatedVer = generateVerificationString(info, hash).version; 559 560 if (!ver.equals(calculatedVer)) 561 return false; 562 563 return true; 564 } 565 566 /** 567 * 568 * @param info 569 * @return true if the stanza(/packet) extensions is ill-formed 570 */ 571 protected static boolean verifyPacketExtensions(DiscoverInfo info) { 572 List<FormField> foundFormTypes = new LinkedList<>(); 573 for (ExtensionElement pe : info.getExtensions()) { 574 if (pe.getNamespace().equals(DataForm.NAMESPACE)) { 575 DataForm df = (DataForm) pe; 576 for (FormField f : df.getFields()) { 577 if (f.getVariable().equals("FORM_TYPE")) { 578 for (FormField fft : foundFormTypes) { 579 if (f.equals(fft)) 580 return true; 581 } 582 foundFormTypes.add(f); 583 } 584 } 585 } 586 } 587 return false; 588 } 589 590 protected static CapsVersionAndHash generateVerificationString(DiscoverInfo discoverInfo) { 591 return generateVerificationString(discoverInfo, null); 592 } 593 594 /** 595 * Generates a XEP-115 Verification String 596 * 597 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115 598 * Verification String</a> 599 * 600 * @param discoverInfo 601 * @param hash 602 * the used hash function, if null, default hash will be used 603 * @return The generated verification String or null if the hash is not 604 * supported 605 */ 606 protected static CapsVersionAndHash generateVerificationString(DiscoverInfo discoverInfo, String hash) { 607 if (hash == null) { 608 hash = DEFAULT_HASH; 609 } 610 // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1" 611 MessageDigest md = SUPPORTED_HASHES.get(hash.toUpperCase(Locale.US)); 612 if (md == null) 613 return null; 614 // Then transform the hash to lowercase, as this value will be put on the wire within the caps element's hash 615 // attribute. I'm not sure if the standard is case insensitive here, but let's assume that even it is, there could 616 // be "broken" implementation in the wild, so we *always* transform to lowercase. 617 hash = hash.toLowerCase(Locale.US); 618 619 DataForm extendedInfo = DataForm.from(discoverInfo); 620 621 // 1. Initialize an empty string S ('sb' in this method). 622 StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't 623 // need thread-safe StringBuffer 624 625 // 2. Sort the service discovery identities by category and then by 626 // type and then by xml:lang 627 // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/' 628 // [NAME]. Note that each slash is included even if the LANG or 629 // NAME is not included (in accordance with XEP-0030, the category and 630 // type MUST be included. 631 SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<>(); 632 633 sortedIdentities.addAll(discoverInfo.getIdentities()); 634 635 // 3. For each identity, append the 'category/type/lang/name' to S, 636 // followed by the '<' character. 637 for (DiscoverInfo.Identity identity : sortedIdentities) { 638 sb.append(identity.getCategory()); 639 sb.append('/'); 640 sb.append(identity.getType()); 641 sb.append('/'); 642 sb.append(identity.getLanguage() == null ? "" : identity.getLanguage()); 643 sb.append('/'); 644 sb.append(identity.getName() == null ? "" : identity.getName()); 645 sb.append('<'); 646 } 647 648 // 4. Sort the supported service discovery features. 649 SortedSet<String> features = new TreeSet<>(); 650 for (Feature f : discoverInfo.getFeatures()) 651 features.add(f.getVar()); 652 653 // 5. For each feature, append the feature to S, followed by the '<' 654 // character 655 for (String f : features) { 656 sb.append(f); 657 sb.append('<'); 658 } 659 660 // only use the data form for calculation is it has a hidden FORM_TYPE 661 // field 662 // see XEP-0115 5.4 step 3.6 663 if (extendedInfo != null && extendedInfo.hasHiddenFormTypeField()) { 664 synchronized (extendedInfo) { 665 // 6. If the service discovery information response includes 666 // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e., 667 // by the XML character data of the <value/> element). 668 SortedSet<FormField> fs = new TreeSet<>(new Comparator<FormField>() { 669 @Override 670 public int compare(FormField f1, FormField f2) { 671 return f1.getVariable().compareTo(f2.getVariable()); 672 } 673 }); 674 675 FormField ft = null; 676 677 for (FormField f : extendedInfo.getFields()) { 678 if (!f.getVariable().equals("FORM_TYPE")) { 679 fs.add(f); 680 } else { 681 ft = f; 682 } 683 } 684 685 // Add FORM_TYPE values 686 if (ft != null) { 687 formFieldValuesToCaps(ft.getValues(), sb); 688 } 689 690 // 7. 3. For each field other than FORM_TYPE: 691 // 1. Append the value of the "var" attribute, followed by the 692 // '<' character. 693 // 2. Sort values by the XML character data of the <value/> 694 // element. 695 // 3. For each <value/> element, append the XML character data, 696 // followed by the '<' character. 697 for (FormField f : fs) { 698 sb.append(f.getVariable()); 699 sb.append('<'); 700 formFieldValuesToCaps(f.getValues(), sb); 701 } 702 } 703 } 704 // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC 705 // 3269). 706 // 9. Compute the verification string by hashing S using the algorithm 707 // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC 708 // 3174). 709 // The hashed data MUST be generated with binary output and 710 // encoded using Base64 as specified in Section 4 of RFC 4648 711 // (note: the Base64 output MUST NOT include whitespace and MUST set 712 // padding bits to zero). 713 byte[] bytes; 714 try { 715 bytes = sb.toString().getBytes(StringUtils.UTF8); 716 } 717 catch (UnsupportedEncodingException e) { 718 throw new AssertionError(e); 719 } 720 byte[] digest; 721 synchronized (md) { 722 digest = md.digest(bytes); 723 } 724 String version = Base64.encodeToString(digest); 725 return new CapsVersionAndHash(version, hash); 726 } 727 728 private static void formFieldValuesToCaps(List<String> i, StringBuilder sb) { 729 SortedSet<String> fvs = new TreeSet<>(); 730 fvs.addAll(i); 731 for (String fv : fvs) { 732 sb.append(fv); 733 sb.append('<'); 734 } 735 } 736 737 public static class NodeVerHash { 738 private String node; 739 private String hash; 740 private String ver; 741 private String nodeVer; 742 743 NodeVerHash(String node, CapsVersionAndHash capsVersionAndHash) { 744 this(node, capsVersionAndHash.version, capsVersionAndHash.hash); 745 } 746 747 NodeVerHash(String node, String ver, String hash) { 748 this.node = node; 749 this.ver = ver; 750 this.hash = hash; 751 nodeVer = node + "#" + ver; 752 } 753 754 public String getNodeVer() { 755 return nodeVer; 756 } 757 758 public String getNode() { 759 return node; 760 } 761 762 public String getHash() { 763 return hash; 764 } 765 766 public String getVer() { 767 return ver; 768 } 769 } 770}