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