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