| /** |
| * Copyright 2009 Jonas Ã…dahl. |
| * Copyright 2011-2013 Florian Schmaus |
| * |
| * All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package org.jivesoftware.smackx.entitycaps; |
| |
| import org.jivesoftware.smack.Connection; |
| import org.jivesoftware.smack.ConnectionCreationListener; |
| import org.jivesoftware.smack.ConnectionListener; |
| import org.jivesoftware.smack.PacketInterceptor; |
| import org.jivesoftware.smack.PacketListener; |
| import org.jivesoftware.smack.SmackConfiguration; |
| import org.jivesoftware.smack.XMPPConnection; |
| import org.jivesoftware.smack.XMPPException; |
| import org.jivesoftware.smack.packet.IQ; |
| import org.jivesoftware.smack.packet.Packet; |
| import org.jivesoftware.smack.packet.PacketExtension; |
| import org.jivesoftware.smack.packet.Presence; |
| import org.jivesoftware.smack.filter.NotFilter; |
| import org.jivesoftware.smack.filter.PacketFilter; |
| import org.jivesoftware.smack.filter.AndFilter; |
| import org.jivesoftware.smack.filter.PacketTypeFilter; |
| import org.jivesoftware.smack.filter.PacketExtensionFilter; |
| import org.jivesoftware.smack.util.Base64; |
| import org.jivesoftware.smack.util.Cache; |
| import org.jivesoftware.smackx.Form; |
| import org.jivesoftware.smackx.FormField; |
| import org.jivesoftware.smackx.NodeInformationProvider; |
| import org.jivesoftware.smackx.ServiceDiscoveryManager; |
| import org.jivesoftware.smackx.entitycaps.cache.EntityCapsPersistentCache; |
| import org.jivesoftware.smackx.entitycaps.packet.CapsExtension; |
| import org.jivesoftware.smackx.packet.DiscoverInfo; |
| import org.jivesoftware.smackx.packet.DataForm; |
| import org.jivesoftware.smackx.packet.DiscoverInfo.Feature; |
| import org.jivesoftware.smackx.packet.DiscoverInfo.Identity; |
| import org.jivesoftware.smackx.packet.DiscoverItems.Item; |
| |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Queue; |
| import java.util.SortedSet; |
| import java.util.TreeSet; |
| import java.util.WeakHashMap; |
| import java.util.concurrent.ConcurrentLinkedQueue; |
| import java.io.IOException; |
| import java.lang.ref.WeakReference; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| |
| /** |
| * Keeps track of entity capabilities. |
| * |
| * @author Florian Schmaus |
| */ |
| public class EntityCapsManager { |
| |
| public static final String NAMESPACE = "http://jabber.org/protocol/caps"; |
| public static final String ELEMENT = "c"; |
| |
| private static final String ENTITY_NODE = "http://www.igniterealtime.org/projects/smack"; |
| private static final Map<String, MessageDigest> SUPPORTED_HASHES = new HashMap<String, MessageDigest>(); |
| |
| protected static EntityCapsPersistentCache persistentCache; |
| |
| private static Map<Connection, EntityCapsManager> instances = Collections |
| .synchronizedMap(new WeakHashMap<Connection, EntityCapsManager>()); |
| |
| /** |
| * Map of (node + '#" + hash algorithm) to DiscoverInfo data |
| */ |
| protected static Map<String, DiscoverInfo> caps = new Cache<String, DiscoverInfo>(1000, -1); |
| |
| /** |
| * Map of Full JID -> DiscoverInfo/null. In case of c2s connection the |
| * key is formed as user@server/resource (resource is required) In case of |
| * link-local connection the key is formed as user@host (no resource) In |
| * case of a server or component the key is formed as domain |
| */ |
| protected static Map<String, NodeVerHash> jidCaps = new Cache<String, NodeVerHash>(10000, -1); |
| |
| static { |
| Connection.addConnectionCreationListener(new ConnectionCreationListener() { |
| public void connectionCreated(Connection connection) { |
| if (connection instanceof XMPPConnection) |
| new EntityCapsManager(connection); |
| } |
| }); |
| |
| try { |
| MessageDigest sha1MessageDigest = MessageDigest.getInstance("SHA-1"); |
| SUPPORTED_HASHES.put("sha-1", sha1MessageDigest); |
| } catch (NoSuchAlgorithmException e) { |
| // Ignore |
| } |
| } |
| |
| private WeakReference<Connection> weakRefConnection; |
| private ServiceDiscoveryManager sdm; |
| private boolean entityCapsEnabled; |
| private String currentCapsVersion; |
| private boolean presenceSend = false; |
| private Queue<String> lastLocalCapsVersions = new ConcurrentLinkedQueue<String>(); |
| |
| /** |
| * Add DiscoverInfo to the database. |
| * |
| * @param nodeVer |
| * The node and verification String (e.g. |
| * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). |
| * @param info |
| * DiscoverInfo for the specified node. |
| */ |
| public static void addDiscoverInfoByNode(String nodeVer, DiscoverInfo info) { |
| caps.put(nodeVer, info); |
| |
| if (persistentCache != null) |
| persistentCache.addDiscoverInfoByNodePersistent(nodeVer, info); |
| } |
| |
| /** |
| * Get the Node version (node#ver) of a JID. Returns a String or null if |
| * EntiyCapsManager does not have any information. |
| * |
| * @param user |
| * the user (Full JID) |
| * @return the node version (node#ver) or null |
| */ |
| public static String getNodeVersionByJid(String jid) { |
| NodeVerHash nvh = jidCaps.get(jid); |
| if (nvh != null) { |
| return nvh.nodeVer; |
| } else { |
| return null; |
| } |
| } |
| |
| public static NodeVerHash getNodeVerHashByJid(String jid) { |
| return jidCaps.get(jid); |
| } |
| |
| /** |
| * Get the discover info given a user name. The discover info is returned if |
| * the user has a node#ver associated with it and the node#ver has a |
| * discover info associated with it. |
| * |
| * @param user |
| * user name (Full JID) |
| * @return the discovered info |
| */ |
| public static DiscoverInfo getDiscoverInfoByUser(String user) { |
| NodeVerHash nvh = jidCaps.get(user); |
| if (nvh == null) |
| return null; |
| |
| return getDiscoveryInfoByNodeVer(nvh.nodeVer); |
| } |
| |
| /** |
| * Retrieve DiscoverInfo for a specific node. |
| * |
| * @param nodeVer |
| * The node name (e.g. |
| * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). |
| * @return The corresponding DiscoverInfo or null if none is known. |
| */ |
| public static DiscoverInfo getDiscoveryInfoByNodeVer(String nodeVer) { |
| DiscoverInfo info = caps.get(nodeVer); |
| if (info != null) |
| info = new DiscoverInfo(info); |
| |
| return info; |
| } |
| |
| /** |
| * Set the persistent cache implementation |
| * |
| * @param cache |
| * @throws IOException |
| */ |
| public static void setPersistentCache(EntityCapsPersistentCache cache) throws IOException { |
| if (persistentCache != null) |
| throw new IllegalStateException("Entity Caps Persistent Cache was already set"); |
| persistentCache = cache; |
| persistentCache.replay(); |
| } |
| |
| /** |
| * Sets the maximum Cache size for the JID to nodeVer Cache |
| * |
| * @param maxCacheSize |
| */ |
| @SuppressWarnings("rawtypes") |
| public static void setJidCapsMaxCacheSize(int maxCacheSize) { |
| ((Cache) jidCaps).setMaxCacheSize(maxCacheSize); |
| } |
| |
| /** |
| * Sets the maximum Cache size for the nodeVer to DiscoverInfo Cache |
| * |
| * @param maxCacheSize |
| */ |
| @SuppressWarnings("rawtypes") |
| public static void setCapsMaxCacheSize(int maxCacheSize) { |
| ((Cache) caps).setMaxCacheSize(maxCacheSize); |
| } |
| |
| private EntityCapsManager(Connection connection) { |
| this.weakRefConnection = new WeakReference<Connection>(connection); |
| this.sdm = ServiceDiscoveryManager.getInstanceFor(connection); |
| init(); |
| } |
| |
| private void init() { |
| Connection connection = weakRefConnection.get(); |
| instances.put(connection, this); |
| |
| connection.addConnectionListener(new ConnectionListener() { |
| public void connectionClosed() { |
| // Unregister this instance since the connection has been closed |
| presenceSend = false; |
| instances.remove(weakRefConnection.get()); |
| } |
| |
| public void connectionClosedOnError(Exception e) { |
| presenceSend = false; |
| } |
| |
| public void reconnectionFailed(Exception e) { |
| // ignore |
| } |
| |
| public void reconnectingIn(int seconds) { |
| // ignore |
| } |
| |
| public void reconnectionSuccessful() { |
| // ignore |
| } |
| }); |
| |
| // This calculates the local entity caps version |
| updateLocalEntityCaps(); |
| |
| if (SmackConfiguration.autoEnableEntityCaps()) |
| enableEntityCaps(); |
| |
| PacketFilter packetFilter = new AndFilter(new PacketTypeFilter(Presence.class), new PacketExtensionFilter( |
| ELEMENT, NAMESPACE)); |
| connection.addPacketListener(new PacketListener() { |
| // Listen for remote presence stanzas with the caps extension |
| // If we receive such a stanza, record the JID and nodeVer |
| @Override |
| public void processPacket(Packet packet) { |
| if (!entityCapsEnabled()) |
| return; |
| |
| CapsExtension ext = (CapsExtension) packet.getExtension(EntityCapsManager.ELEMENT, |
| EntityCapsManager.NAMESPACE); |
| |
| String hash = ext.getHash().toLowerCase(); |
| if (!SUPPORTED_HASHES.containsKey(hash)) |
| return; |
| |
| String from = packet.getFrom(); |
| String node = ext.getNode(); |
| String ver = ext.getVer(); |
| |
| jidCaps.put(from, new NodeVerHash(node, ver, hash)); |
| } |
| |
| }, packetFilter); |
| |
| packetFilter = new AndFilter(new PacketTypeFilter(Presence.class), new NotFilter(new PacketExtensionFilter( |
| ELEMENT, NAMESPACE))); |
| connection.addPacketListener(new PacketListener() { |
| @Override |
| public void processPacket(Packet packet) { |
| // always remove the JID from the map, even if entityCaps are |
| // disabled |
| String from = packet.getFrom(); |
| jidCaps.remove(from); |
| } |
| }, packetFilter); |
| |
| packetFilter = new PacketTypeFilter(Presence.class); |
| connection.addPacketSendingListener(new PacketListener() { |
| @Override |
| public void processPacket(Packet packet) { |
| presenceSend = true; |
| } |
| }, packetFilter); |
| |
| // Intercept presence packages and add caps data when intended. |
| // XEP-0115 specifies that a client SHOULD include entity capabilities |
| // with every presence notification it sends. |
| PacketFilter capsPacketFilter = new PacketTypeFilter(Presence.class); |
| PacketInterceptor packetInterceptor = new PacketInterceptor() { |
| public void interceptPacket(Packet packet) { |
| if (!entityCapsEnabled) |
| return; |
| |
| CapsExtension caps = new CapsExtension(ENTITY_NODE, getCapsVersion(), "sha-1"); |
| packet.addExtension(caps); |
| } |
| }; |
| connection.addPacketInterceptor(packetInterceptor, capsPacketFilter); |
| // It's important to do this as last action. Since it changes the |
| // behavior of the SDM in some ways |
| sdm.setEntityCapsManager(this); |
| } |
| |
| public static synchronized EntityCapsManager getInstanceFor(Connection connection) { |
| // For testing purposed forbid EntityCaps for non XMPPConnections |
| // it may work on BOSH connections too |
| if (!(connection instanceof XMPPConnection)) |
| return null; |
| |
| if (SUPPORTED_HASHES.size() <= 0) |
| return null; |
| |
| EntityCapsManager entityCapsManager = instances.get(connection); |
| |
| if (entityCapsManager == null) { |
| entityCapsManager = new EntityCapsManager(connection); |
| } |
| |
| return entityCapsManager; |
| } |
| |
| public void enableEntityCaps() { |
| // Add Entity Capabilities (XEP-0115) feature node. |
| sdm.addFeature(NAMESPACE); |
| updateLocalEntityCaps(); |
| entityCapsEnabled = true; |
| } |
| |
| public void disableEntityCaps() { |
| entityCapsEnabled = false; |
| sdm.removeFeature(NAMESPACE); |
| } |
| |
| public boolean entityCapsEnabled() { |
| return entityCapsEnabled; |
| } |
| |
| /** |
| * Remove a record telling what entity caps node a user has. |
| * |
| * @param user |
| * the user (Full JID) |
| */ |
| public void removeUserCapsNode(String user) { |
| jidCaps.remove(user); |
| } |
| |
| /** |
| * Get our own caps version. The version depends on the enabled features. A |
| * caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI=' |
| * |
| * @return our own caps version |
| */ |
| public String getCapsVersion() { |
| return currentCapsVersion; |
| } |
| |
| /** |
| * Returns the local entity's NodeVer (e.g. |
| * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI= |
| * ) |
| * |
| * @return |
| */ |
| public String getLocalNodeVer() { |
| return ENTITY_NODE + '#' + getCapsVersion(); |
| } |
| |
| /** |
| * Returns true if Entity Caps are supported by a given JID |
| * |
| * @param jid |
| * @return |
| */ |
| public boolean areEntityCapsSupported(String jid) { |
| if (jid == null) |
| return false; |
| |
| try { |
| DiscoverInfo result = sdm.discoverInfo(jid); |
| return result.containsFeature(NAMESPACE); |
| } catch (XMPPException e) { |
| return false; |
| } |
| } |
| |
| /** |
| * Returns true if Entity Caps are supported by the local service/server |
| * |
| * @return |
| */ |
| public boolean areEntityCapsSupportedByServer() { |
| return areEntityCapsSupported(weakRefConnection.get().getServiceName()); |
| } |
| |
| /** |
| * Updates the local user Entity Caps information with the data provided |
| * |
| * If we are connected and there was already a presence send, another |
| * presence is send to inform others about your new Entity Caps node string. |
| * |
| * @param discoverInfo |
| * the local users discover info (mostly the service discovery |
| * features) |
| * @param identityType |
| * the local users identity type |
| * @param identityName |
| * the local users identity name |
| * @param extendedInfo |
| * the local users extended info |
| */ |
| public void updateLocalEntityCaps() { |
| Connection connection = weakRefConnection.get(); |
| |
| DiscoverInfo discoverInfo = new DiscoverInfo(); |
| discoverInfo.setType(IQ.Type.RESULT); |
| discoverInfo.setNode(getLocalNodeVer()); |
| if (connection != null) |
| discoverInfo.setFrom(connection.getUser()); |
| sdm.addDiscoverInfoTo(discoverInfo); |
| |
| currentCapsVersion = generateVerificationString(discoverInfo, "sha-1"); |
| addDiscoverInfoByNode(ENTITY_NODE + '#' + currentCapsVersion, discoverInfo); |
| if (lastLocalCapsVersions.size() > 10) { |
| String oldCapsVersion = lastLocalCapsVersions.poll(); |
| sdm.removeNodeInformationProvider(ENTITY_NODE + '#' + oldCapsVersion); |
| } |
| lastLocalCapsVersions.add(currentCapsVersion); |
| |
| caps.put(currentCapsVersion, discoverInfo); |
| if (connection != null) |
| jidCaps.put(connection.getUser(), new NodeVerHash(ENTITY_NODE, currentCapsVersion, "sha-1")); |
| |
| sdm.setNodeInformationProvider(ENTITY_NODE + '#' + currentCapsVersion, new NodeInformationProvider() { |
| List<String> features = sdm.getFeaturesList(); |
| List<Identity> identities = new LinkedList<Identity>(ServiceDiscoveryManager.getIdentities()); |
| List<PacketExtension> packetExtensions = sdm.getExtendedInfoAsList(); |
| |
| @Override |
| public List<Item> getNodeItems() { |
| return null; |
| } |
| |
| @Override |
| public List<String> getNodeFeatures() { |
| return features; |
| } |
| |
| @Override |
| public List<Identity> getNodeIdentities() { |
| return identities; |
| } |
| |
| @Override |
| public List<PacketExtension> getNodePacketExtensions() { |
| return packetExtensions; |
| } |
| }); |
| |
| // Send an empty presence, and let the packet intercepter |
| // add a <c/> node to it. |
| // See http://xmpp.org/extensions/xep-0115.html#advertise |
| // We only send a presence packet if there was already one send |
| // to respect ConnectionConfiguration.isSendPresence() |
| if (connection != null && connection.isAuthenticated() && presenceSend) { |
| Presence presence = new Presence(Presence.Type.available); |
| connection.sendPacket(presence); |
| } |
| } |
| |
| /** |
| * Verify DisoverInfo and Caps Node as defined in XEP-0115 5.4 Processing |
| * Method |
| * |
| * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115 |
| * 5.4 Processing Method</a> |
| * |
| * @param capsNode |
| * the caps node (i.e. node#ver) |
| * @param info |
| * @return true if it's valid and should be cache, false if not |
| */ |
| public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) { |
| // step 3.3 check for duplicate identities |
| if (info.containsDuplicateIdentities()) |
| return false; |
| |
| // step 3.4 check for duplicate features |
| if (info.containsDuplicateFeatures()) |
| return false; |
| |
| // step 3.5 check for well-formed packet extensions |
| if (verifyPacketExtensions(info)) |
| return false; |
| |
| String calculatedVer = generateVerificationString(info, hash); |
| |
| if (!ver.equals(calculatedVer)) |
| return false; |
| |
| return true; |
| } |
| |
| /** |
| * |
| * @param info |
| * @return true if the packet extensions is ill-formed |
| */ |
| protected static boolean verifyPacketExtensions(DiscoverInfo info) { |
| List<FormField> foundFormTypes = new LinkedList<FormField>(); |
| for (Iterator<PacketExtension> i = info.getExtensions().iterator(); i.hasNext();) { |
| PacketExtension pe = i.next(); |
| if (pe.getNamespace().equals(Form.NAMESPACE)) { |
| DataForm df = (DataForm) pe; |
| for (Iterator<FormField> it = df.getFields(); it.hasNext();) { |
| FormField f = it.next(); |
| if (f.getVariable().equals("FORM_TYPE")) { |
| for (FormField fft : foundFormTypes) { |
| if (f.equals(fft)) |
| return true; |
| } |
| foundFormTypes.add(f); |
| } |
| } |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Generates a XEP-115 Verification String |
| * |
| * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115 |
| * Verification String</a> |
| * |
| * @param discoverInfo |
| * @param hash |
| * the used hash function |
| * @return The generated verification String or null if the hash is not |
| * supported |
| */ |
| protected static String generateVerificationString(DiscoverInfo discoverInfo, String hash) { |
| MessageDigest md = SUPPORTED_HASHES.get(hash.toLowerCase()); |
| if (md == null) |
| return null; |
| |
| DataForm extendedInfo = (DataForm) discoverInfo.getExtension(Form.ELEMENT, Form.NAMESPACE); |
| |
| // 1. Initialize an empty string S ('sb' in this method). |
| StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't |
| // need thread-safe StringBuffer |
| |
| // 2. Sort the service discovery identities by category and then by |
| // type and then by xml:lang |
| // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/' |
| // [NAME]. Note that each slash is included even if the LANG or |
| // NAME is not included (in accordance with XEP-0030, the category and |
| // type MUST be included. |
| SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<DiscoverInfo.Identity>(); |
| |
| for (Iterator<DiscoverInfo.Identity> it = discoverInfo.getIdentities(); it.hasNext();) |
| sortedIdentities.add(it.next()); |
| |
| // 3. For each identity, append the 'category/type/lang/name' to S, |
| // followed by the '<' character. |
| for (Iterator<DiscoverInfo.Identity> it = sortedIdentities.iterator(); it.hasNext();) { |
| DiscoverInfo.Identity identity = it.next(); |
| sb.append(identity.getCategory()); |
| sb.append("/"); |
| sb.append(identity.getType()); |
| sb.append("/"); |
| sb.append(identity.getLanguage() == null ? "" : identity.getLanguage()); |
| sb.append("/"); |
| sb.append(identity.getName() == null ? "" : identity.getName()); |
| sb.append("<"); |
| } |
| |
| // 4. Sort the supported service discovery features. |
| SortedSet<String> features = new TreeSet<String>(); |
| for (Iterator<Feature> it = discoverInfo.getFeatures(); it.hasNext();) |
| features.add(it.next().getVar()); |
| |
| // 5. For each feature, append the feature to S, followed by the '<' |
| // character |
| for (String f : features) { |
| sb.append(f); |
| sb.append("<"); |
| } |
| |
| // only use the data form for calculation is it has a hidden FORM_TYPE |
| // field |
| // see XEP-0115 5.4 step 3.6 |
| if (extendedInfo != null && extendedInfo.hasHiddenFormTypeField()) { |
| synchronized (extendedInfo) { |
| // 6. If the service discovery information response includes |
| // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e., |
| // by the XML character data of the <value/> element). |
| SortedSet<FormField> fs = new TreeSet<FormField>(new Comparator<FormField>() { |
| public int compare(FormField f1, FormField f2) { |
| return f1.getVariable().compareTo(f2.getVariable()); |
| } |
| }); |
| |
| FormField ft = null; |
| |
| for (Iterator<FormField> i = extendedInfo.getFields(); i.hasNext();) { |
| FormField f = i.next(); |
| if (!f.getVariable().equals("FORM_TYPE")) { |
| fs.add(f); |
| } else { |
| ft = f; |
| } |
| } |
| |
| // Add FORM_TYPE values |
| if (ft != null) { |
| formFieldValuesToCaps(ft.getValues(), sb); |
| } |
| |
| // 7. 3. For each field other than FORM_TYPE: |
| // 1. Append the value of the "var" attribute, followed by the |
| // '<' character. |
| // 2. Sort values by the XML character data of the <value/> |
| // element. |
| // 3. For each <value/> element, append the XML character data, |
| // followed by the '<' character. |
| for (FormField f : fs) { |
| sb.append(f.getVariable()); |
| sb.append("<"); |
| formFieldValuesToCaps(f.getValues(), sb); |
| } |
| } |
| } |
| // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC |
| // 3269). |
| // 9. Compute the verification string by hashing S using the algorithm |
| // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC |
| // 3174). |
| // The hashed data MUST be generated with binary output and |
| // encoded using Base64 as specified in Section 4 of RFC 4648 |
| // (note: the Base64 output MUST NOT include whitespace and MUST set |
| // padding bits to zero). |
| byte[] digest = md.digest(sb.toString().getBytes()); |
| return Base64.encodeBytes(digest); |
| } |
| |
| private static void formFieldValuesToCaps(Iterator<String> i, StringBuilder sb) { |
| SortedSet<String> fvs = new TreeSet<String>(); |
| while (i.hasNext()) { |
| fvs.add(i.next()); |
| } |
| for (String fv : fvs) { |
| sb.append(fv); |
| sb.append("<"); |
| } |
| } |
| |
| public static class NodeVerHash { |
| private String node; |
| private String hash; |
| private String ver; |
| private String nodeVer; |
| |
| NodeVerHash(String node, String ver, String hash) { |
| this.node = node; |
| this.ver = ver; |
| this.hash = hash; |
| nodeVer = node + "#" + ver; |
| } |
| |
| public String getNodeVer() { |
| return nodeVer; |
| } |
| |
| public String getNode() { |
| return node; |
| } |
| |
| public String getHash() { |
| return hash; |
| } |
| |
| public String getVer() { |
| return ver; |
| } |
| } |
| } |