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