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