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        // Note that this is a *synchronous* stanza listener to avoid unnecessary feature lookups. If this were to be an
358        // asynchronous listener, then it would be possible that the entity caps information was not processed when the
359        // features of entity are looked up. See SMACK-937.
360        connection.addStanzaListener(new StanzaListener() {
361            // Listen for remote presence stanzas with the caps extension
362            // If we receive such a stanza, record the JID and nodeVer
363            @Override
364            public void processStanza(Stanza packet) {
365                if (!entityCapsEnabled())
366                    return;
367
368                CapsExtension capsExtension = CapsExtension.from(packet);
369                Jid from = packet.getFrom();
370                addCapsExtensionInfo(from, capsExtension);
371            }
372
373        }, PRESENCES_WITH_CAPS);
374
375        Roster.getInstanceFor(connection).addPresenceEventListener(new AbstractPresenceEventListener() {
376            @Override
377            public void presenceUnavailable(FullJid from, Presence presence) {
378                JID_TO_NODEVER_CACHE.remove(from);
379            }
380        });
381
382        // It's important to do this as last action. Since it changes the
383        // behavior of the SDM in some ways
384        sdm.addEntityCapabilitiesChangedListener(new EntityCapabilitiesChangedListener() {
385            @Override
386            public void onEntityCapabilitiesChanged(DiscoverInfo synthesizedDiscoveryInfo) {
387                if (!entityCapsEnabled()) {
388                    return;
389                }
390
391                updateLocalEntityCaps(synthesizedDiscoveryInfo);
392            }
393        });
394    }
395
396    public static synchronized EntityCapsManager getInstanceFor(XMPPConnection connection) {
397        if (SUPPORTED_HASHES.size() <= 0)
398            throw new IllegalStateException("No supported hashes for EntityCapsManager");
399
400        EntityCapsManager entityCapsManager = instances.get(connection);
401
402        if (entityCapsManager == null) {
403            entityCapsManager = new EntityCapsManager(connection);
404        }
405
406        return entityCapsManager;
407    }
408
409    public synchronized void enableEntityCaps() {
410        connection().addPresenceInterceptor(this::addCapsExtension, p -> {
411            return PresenceTypeFilter.AVAILABLE.accept(p);
412        });
413
414        // Add Entity Capabilities (XEP-0115) feature node.
415        sdm.addFeature(NAMESPACE);
416        entityCapsEnabled = true;
417    }
418
419    public synchronized void disableEntityCaps() {
420        entityCapsEnabled = false;
421        sdm.removeFeature(NAMESPACE);
422
423        connection().removePresenceInterceptor(this::addCapsExtension);
424    }
425
426    public boolean entityCapsEnabled() {
427        return entityCapsEnabled;
428    }
429
430    /**
431     * Remove a record telling what entity caps node a user has.
432     *
433     * @param user TODO javadoc me please
434     *            the user (Full JID)
435     */
436    public static void removeUserCapsNode(Jid user) {
437        // While JID_TO_NODEVER_CHACHE has the generic types <Jid, NodeVerHash>, it is ok to call remove with String
438        // arguments, since the same Jid and String representations would be equal and have the same hash code.
439        JID_TO_NODEVER_CACHE.remove(user);
440    }
441
442    /**
443     * Get our own caps version or {@code null} if none is yet set. The version depends on the enabled features.
444     * A caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI='
445     *
446     * @return our own caps version or {@code null}.
447     */
448    public CapsVersionAndHash getCapsVersionAndHash() {
449        return currentCapsVersion;
450    }
451
452    /**
453     * Returns the local entity's NodeVer (e.g.
454     * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI=
455     * )
456     *
457     * @return the local NodeVer
458     */
459    public String getLocalNodeVer() {
460        CapsVersionAndHash capsVersionAndHash = getCapsVersionAndHash();
461        if (capsVersionAndHash == null) {
462            return null;
463        }
464        return entityNode + '#' + capsVersionAndHash.version;
465    }
466
467    /**
468     * Returns true if Entity Caps are supported by a given JID.
469     *
470     * @param jid TODO javadoc me please
471     * @return true if the entity supports Entity Capabilities.
472     * @throws XMPPErrorException if there was an XMPP error returned.
473     * @throws NoResponseException if there was no response from the remote entity.
474     * @throws NotConnectedException if the XMPP connection is not connected.
475     * @throws InterruptedException if the calling thread was interrupted.
476     */
477    public boolean areEntityCapsSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
478        return sdm.supportsFeature(jid, NAMESPACE);
479    }
480
481    /**
482     * Returns true if Entity Caps are supported by the local service/server.
483     *
484     * @return true if the user's server supports Entity Capabilities.
485     * @throws XMPPErrorException if there was an XMPP error returned.
486     * @throws NoResponseException if there was no response from the remote entity.
487     * @throws NotConnectedException if the XMPP connection is not connected.
488     * @throws InterruptedException if the calling thread was interrupted.
489     */
490    public boolean areEntityCapsSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
491        return areEntityCapsSupported(connection().getXMPPServiceDomain());
492    }
493
494    /**
495     * Updates the local user Entity Caps information with the data provided
496     *
497     * If we are connected and there was already a presence send, another
498     * presence is send to inform others about your new Entity Caps node string.
499     *
500     */
501    private void updateLocalEntityCaps(DiscoverInfo synthesizedDiscoveryInfo) {
502        XMPPConnection connection = connection();
503
504        DiscoverInfoBuilder discoverInfoBuilder = synthesizedDiscoveryInfo.asBuilder("synthesized-disco-info-result");
505        // getLocalNodeVer() will return a result only after currentCapsVersion is set. Therefore
506        // set it first and then call getLocalNodeVer()
507        currentCapsVersion = generateVerificationString(discoverInfoBuilder);
508        final String localNodeVer = getLocalNodeVer();
509        discoverInfoBuilder.setNode(localNodeVer);
510
511        final DiscoverInfo discoverInfo = discoverInfoBuilder.build();
512        addDiscoverInfoByNode(localNodeVer, discoverInfo);
513
514        if (lastLocalCapsVersions.size() > 10) {
515            CapsVersionAndHash oldCapsVersion = lastLocalCapsVersions.poll();
516            sdm.removeNodeInformationProvider(entityNode + '#' + oldCapsVersion.version);
517        }
518        lastLocalCapsVersions.add(currentCapsVersion);
519
520        if (connection != null)
521            JID_TO_NODEVER_CACHE.put(connection.getUser(), new NodeVerHash(entityNode, currentCapsVersion));
522
523        final List<Identity> identities = new LinkedList<>(ServiceDiscoveryManager.getInstanceFor(connection).getIdentities());
524        sdm.setNodeInformationProvider(localNodeVer, new AbstractNodeInformationProvider() {
525            List<String> features = sdm.getFeatures();
526            List<DataForm> packetExtensions = sdm.getExtendedInfo();
527            @Override
528            public List<String> getNodeFeatures() {
529                return features;
530            }
531            @Override
532            public List<Identity> getNodeIdentities() {
533                return identities;
534            }
535            @Override
536            public List<DataForm> getNodePacketExtensions() {
537                return packetExtensions;
538            }
539        });
540    }
541
542    /**
543     * Verify DiscoverInfo and Caps Node as defined in XEP-0115 5.4 Processing
544     * Method.
545     *
546     * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115
547     *      5.4 Processing Method</a>
548     *
549     * @param ver TODO javadoc me please
550     * @param hash TODO javadoc me please
551     * @param info TODO javadoc me please
552     * @return true if it's valid and should be cache, false if not
553     */
554    public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) {
555        // step 3.3 check for duplicate identities
556        if (info.containsDuplicateIdentities())
557            return false;
558
559        // step 3.4 check for duplicate features
560        if (info.containsDuplicateFeatures())
561            return false;
562
563        // step 3.5 check for well-formed packet extensions
564        if (!verifyPacketExtensions(info))
565            return false;
566
567        String calculatedVer = generateVerificationString(info, hash).version;
568
569        if (!ver.equals(calculatedVer))
570            return false;
571
572        return true;
573    }
574
575    /**
576     * Verify that the given discovery info is not ill-formed.
577     *
578     * @param info the discovery info to verify.
579     * @return true if the stanza extensions is not ill-formed
580     */
581    private static boolean verifyPacketExtensions(DiscoverInfo info) {
582        Set<String> foundFormTypes = new HashSet<>();
583        List<DataForm> dataForms = info.getExtensions(DataForm.class);
584        for (DataForm dataForm : dataForms) {
585            FormField formFieldTypeField = dataForm.getHiddenFormTypeField();
586            if (formFieldTypeField == null) {
587                continue;
588            }
589
590            String type = formFieldTypeField.getFirstValue();
591            boolean noDuplicate = foundFormTypes.add(type);
592            if (!noDuplicate) {
593                // Ill-formed extension: duplicate forms (by form field type string).
594                return false;
595            }
596        }
597
598        return true;
599    }
600
601    protected static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo) {
602        return generateVerificationString(discoverInfo, null);
603    }
604
605    /**
606     * Generates a XEP-115 Verification String
607     *
608     * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115
609     *      Verification String</a>
610     *
611     * @param discoverInfo TODO javadoc me please
612     * @param hash TODO javadoc me please
613     *            the used hash function, if null, default hash will be used
614     * @return The generated verification String or null if the hash is not
615     *         supported
616     */
617    protected static CapsVersionAndHash generateVerificationString(DiscoverInfoView discoverInfo, String hash) {
618        if (hash == null) {
619            hash = DEFAULT_HASH;
620        }
621        // SUPPORTED_HASHES uses the format of MessageDigest, which is uppercase, e.g. "SHA-1" instead of "sha-1"
622        MessageDigest md = SUPPORTED_HASHES.get(hash.toUpperCase(Locale.US));
623        if (md == null)
624            return null;
625        // Then transform the hash to lowercase, as this value will be put on the wire within the caps element's hash
626        // attribute. I'm not sure if the standard is case insensitive here, but let's assume that even it is, there could
627        // be "broken" implementation in the wild, so we *always* transform to lowercase.
628        hash = hash.toLowerCase(Locale.US);
629
630        // 1. Initialize an empty string S ('sb' in this method).
631        StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't
632                                                // need thread-safe StringBuffer
633
634        // 2. Sort the service discovery identities by category and then by
635        // type and then by xml:lang
636        // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/'
637        // [NAME]. Note that each slash is included even if the LANG or
638        // NAME is not included (in accordance with XEP-0030, the category and
639        // type MUST be included.
640        SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<>();
641
642        sortedIdentities.addAll(discoverInfo.getIdentities());
643
644        // 3. For each identity, append the 'category/type/lang/name' to S,
645        // followed by the '<' character.
646        for (DiscoverInfo.Identity identity : sortedIdentities) {
647            sb.append(identity.getCategory());
648            sb.append('/');
649            sb.append(identity.getType());
650            sb.append('/');
651            sb.append(identity.getLanguage() == null ? "" : identity.getLanguage());
652            sb.append('/');
653            sb.append(identity.getName() == null ? "" : identity.getName());
654            sb.append('<');
655        }
656
657        // 4. Sort the supported service discovery features.
658        SortedSet<String> features = new TreeSet<>();
659        for (Feature f : discoverInfo.getFeatures())
660            features.add(f.getVar());
661
662        // 5. For each feature, append the feature to S, followed by the '<'
663        // character
664        for (String f : features) {
665            sb.append(f);
666            sb.append('<');
667        }
668
669        List<DataForm> extendedInfos = discoverInfo.getExtensions(DataForm.class);
670        for (DataForm extendedInfo : extendedInfos) {
671            if (!extendedInfo.hasHiddenFormTypeField()) {
672                // Only use the data form for calculation is it has a hidden FORM_TYPE field.
673                // See XEP-0115 5.4 step 3.f
674                continue;
675            }
676
677            // 6. If the service discovery information response includes
678            // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e.,
679            // by the XML character data of the <value/> element).
680            SortedSet<FormField> fs = new TreeSet<>(new Comparator<FormField>() {
681                @Override
682                public int compare(FormField f1, FormField f2) {
683                    return f1.getFieldName().compareTo(f2.getFieldName());
684                }
685            });
686
687            for (FormField f : extendedInfo.getFields()) {
688                if (!f.getFieldName().equals("FORM_TYPE")) {
689                    fs.add(f);
690                }
691            }
692
693            // Add FORM_TYPE values
694            formFieldValuesToCaps(Collections.singletonList(extendedInfo.getFormType()), sb);
695
696            // 7. 3. For each field other than FORM_TYPE:
697            // 1. Append the value of the "var" attribute, followed by the
698            // '<' character.
699            // 2. Sort values by the XML character data of the <value/>
700            // element.
701            // 3. For each <value/> element, append the XML character data,
702            // followed by the '<' character.
703            for (FormField f : fs) {
704                sb.append(f.getFieldName());
705                sb.append('<');
706                formFieldValuesToCaps(f.getRawValueCharSequences(), sb);
707            }
708        }
709
710        // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC
711        // 3269).
712        // 9. Compute the verification string by hashing S using the algorithm
713        // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC
714        // 3174).
715        // The hashed data MUST be generated with binary output and
716        // encoded using Base64 as specified in Section 4 of RFC 4648
717        // (note: the Base64 output MUST NOT include whitespace and MUST set
718        // padding bits to zero).
719        byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8);
720        byte[] digest;
721        synchronized (md) {
722            digest = md.digest(bytes);
723        }
724        String version = Base64.encodeToString(digest);
725        return new CapsVersionAndHash(version, hash);
726    }
727
728    private static void formFieldValuesToCaps(List<? extends CharSequence> i, StringBuilder sb) {
729        SortedSet<CharSequence> fvs = new TreeSet<>();
730        fvs.addAll(i);
731        for (CharSequence fv : fvs) {
732            sb.append(fv);
733            sb.append('<');
734        }
735    }
736
737    public static class NodeVerHash {
738        private String node;
739        private String hash;
740        private String ver;
741        private String nodeVer;
742
743        NodeVerHash(String node, CapsVersionAndHash capsVersionAndHash) {
744            this(node, capsVersionAndHash.version, capsVersionAndHash.hash);
745        }
746
747        NodeVerHash(String node, String ver, String hash) {
748            this.node = node;
749            this.ver = ver;
750            this.hash = hash;
751            nodeVer = node + "#" + ver;
752        }
753
754        public String getNodeVer() {
755            return nodeVer;
756        }
757
758        public String getNode() {
759            return node;
760        }
761
762        public String getHash() {
763            return hash;
764        }
765
766        public String getVer() {
767            return ver;
768        }
769    }
770}