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