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