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 -&gt; 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}