diff --git a/source/org/jivesoftware/smack/Connection.java b/source/org/jivesoftware/smack/Connection.java index bd289e34a..5dca4f8a4 100644 --- a/source/org/jivesoftware/smack/Connection.java +++ b/source/org/jivesoftware/smack/Connection.java @@ -211,6 +211,11 @@ public abstract class Connection { */ private String serviceCapsNode; + /** + * Stores whether the server supports rosterVersioning + */ + private boolean rosterVersioningSupported = false; + protected XMPPInputOutputStream compressionHandler; /** @@ -829,6 +834,22 @@ public abstract class Connection { return serviceCapsNode; } + /** + * Returns true if the server supports roster versioning as defined in XEP-0237. + * + * @return true if the server supports roster versioning + */ + public boolean isRosterVersioningSupported() { + return rosterVersioningSupported; + } + + /** + * Indicates that the server supports roster versioning as defined in XEP-0237. + */ + protected void setRosterVersioningSupported() { + rosterVersioningSupported = true; + } + /** * A wrapper class to associate a packet filter with a listener. */ diff --git a/source/org/jivesoftware/smack/ConnectionConfiguration.java b/source/org/jivesoftware/smack/ConnectionConfiguration.java index 25e4efec7..396acea3e 100644 --- a/source/org/jivesoftware/smack/ConnectionConfiguration.java +++ b/source/org/jivesoftware/smack/ConnectionConfiguration.java @@ -27,6 +27,7 @@ import org.jivesoftware.smack.util.dns.HostAddress; import javax.net.SocketFactory; import javax.net.ssl.SSLContext; import javax.security.auth.callback.CallbackHandler; + import java.io.File; import java.util.ArrayList; import java.util.Collections; @@ -90,8 +91,13 @@ public class ConnectionConfiguration implements Cloneable { private boolean sendPresence = true; private boolean rosterLoadedAtLogin = true; private SecurityMode securityMode = SecurityMode.enabled; - - // Holds the proxy information (such as proxyhost, proxyport, username, password etc) + + /** + * Permanent store for the Roster, needed for roster versioning + */ + private RosterStore rosterStore; + + // Holds the proxy information (such as proxyhost, proxyport, username, password etc) protected ProxyInfo proxy; /** @@ -696,6 +702,21 @@ public class ConnectionConfiguration implements Cloneable { return Collections.unmodifiableList(hostAddresses); } + /** + * Set the permanent roster store + */ + public void setRosterStore(RosterStore store) { + rosterStore = store; + } + + /** + * Get the permanent roster store + */ + public RosterStore getRosterStore() { + return rosterStore; + } + + /** * An enumeration for TLS security modes that are available when making a connection * to the XMPP server. diff --git a/source/org/jivesoftware/smack/DefaultRosterStore.java b/source/org/jivesoftware/smack/DefaultRosterStore.java new file mode 100644 index 000000000..5f5398905 --- /dev/null +++ b/source/org/jivesoftware/smack/DefaultRosterStore.java @@ -0,0 +1,351 @@ +/** + * 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.smack; + +import java.io.File; +import java.io.FileFilter; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.jivesoftware.smack.packet.RosterPacket; +import org.jivesoftware.smack.packet.RosterPacket.Item; +import org.jivesoftware.smack.util.Base32Encoder; +import org.jivesoftware.smack.util.StringUtils; +import org.xmlpull.mxp1.MXParser; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * Stores roster entries as specified by RFC 6121 for roster versioning + * in a set of files. + * + * @author Lars Noschinski + * @author Fabian Schuetz + */ +public class DefaultRosterStore implements RosterStore { + + private final File fileDir; + + private static final String ENTRY_PREFIX = "entry-"; + private static final String VERSION_FILE_NAME = "__version__"; + private static final String STORE_ID = "DEFAULT_ROSTER_STORE"; + + private static final FileFilter rosterDirFilter = new FileFilter() { + + @Override + public boolean accept(File file) { + String name = file.getName(); + return name.startsWith(ENTRY_PREFIX); + } + + }; + + /** + * @param baseDir + * will be the directory where all roster entries are stored. One + * file for each entry, such that file.name = entry.username. + * There is also one special file '__version__' that contains the + * current version string. + */ + private DefaultRosterStore(final File baseDir) { + this.fileDir = baseDir; + } + + /** + * Creates a new roster store on disk + * + * @param baseDir + * The directory to create the store in. The directory should + * be empty + * @return A {@link DefaultRosterStore} instance if successful, + * null else. + */ + public static DefaultRosterStore init(final File baseDir) { + DefaultRosterStore store = new DefaultRosterStore(baseDir); + if (store.setRosterVersion("")) { + return store; + } + else { + return null; + } + } + + /** + * Opens a roster store + * @param baseDir + * The directory containing the roster store. + * @return A {@link DefaultRosterStore} instance if successful, + * null else. + */ + public static DefaultRosterStore open(final File baseDir) { + DefaultRosterStore store = new DefaultRosterStore(baseDir); + String s = store.readFile(store.getVersionFile()); + if (s != null && s.startsWith(STORE_ID + "\n")) { + return store; + } + else { + return null; + } + } + + private File getVersionFile() { + return new File(fileDir, VERSION_FILE_NAME); + } + + @Override + public List getEntries() { + List entries = new ArrayList(); + + for (File file : fileDir.listFiles(rosterDirFilter)) { + Item entry = readEntry(file); + if (entry == null) { + log("Roster store file '" + file + "' is invalid."); + } + else { + entries.add(entry); + } + } + + return entries; + } + + @Override + public Item getEntry(String bareJid) { + return readEntry(getBareJidFile(bareJid)); + } + + @Override + public String getRosterVersion() { + String s = readFile(getVersionFile()); + if (s == null) { + return null; + } + String[] lines = s.split("\n", 2); + if (lines.length < 2) { + return null; + } + return lines[1]; + } + + private boolean setRosterVersion(String version) { + return writeFile(getVersionFile(), STORE_ID + "\n" + version); + } + + @Override + public boolean addEntry(Item item, String version) { + return addEntryRaw(item) && setRosterVersion(version); + } + + @Override + public boolean removeEntry(String bareJid, String version) { + try { + return getBareJidFile(bareJid).delete() && setRosterVersion(version); + } + catch (SecurityException e) { + e.printStackTrace(); + return false; + } + } + + @Override + public boolean resetEntries(Collection items, String version) { + try { + for (File file : fileDir.listFiles(rosterDirFilter)) { + file.delete(); + } + } catch (SecurityException e) { + e.printStackTrace(); + return false; + } + for (Item item : items) { + if (!addEntryRaw(item)) { + return false; + } + } + return setRosterVersion(version); + } + + private Item readEntry(File file) { + String s = readFile(file); + if (s == null) { + return null; + } + + String user = null; + String name = null; + String type = null; + String status = null; + + List groupNames = new ArrayList(); + + try { + XmlPullParser parser = new MXParser(); + parser.setInput(new StringReader(s)); + + boolean done = false; + while (!done) { + int eventType = parser.next(); + if (eventType == XmlPullParser.START_TAG) { + if (parser.getName().equals("item")) { + user = parser.getAttributeValue(null, "user"); + name = parser.getAttributeValue(null, "name"); + type = parser.getAttributeValue(null, "type"); + status = parser.getAttributeValue(null, "status"); + } + if (parser.getName().equals("group")) { + String group = parser.getAttributeValue(null, "name"); + if (group != null) { + groupNames.add(group); + } + else { + log("Invalid group entry in store entry file " + + file); + } + } + } + else if (eventType == XmlPullParser.END_TAG) { + if (parser.getName().equals("item")) { + done = true; + } + } + } + } + catch (IOException e) { + e.printStackTrace();; + return null; + } + catch (XmlPullParserException e) { + log("Invalid group entry in store entry file " + + file); + e.printStackTrace(); + return null; + } + + if (user == null) { + return null; + } + RosterPacket.Item item = new RosterPacket.Item(user, name); + for (String groupName : groupNames) { + item.addGroupName(groupName); + } + + if (type != null) { + try { + item.setItemType(RosterPacket.ItemType.valueOf(type)); + } + catch (IllegalArgumentException e) { + log("Invalid type in store entry file " + file); + return null; + } + if (status != null) { + RosterPacket.ItemStatus itemStatus = RosterPacket.ItemStatus + .fromString(status); + if (itemStatus == null) { + log("Invalid status in store entry file " + file); + return null; + } + item.setItemStatus(itemStatus); + } + } + + return item; + } + + + private boolean addEntryRaw (Item item) { + StringBuilder s = new StringBuilder(); + s.append(""); + for (String group : item.getGroupNames()) { + s.append(""); + } + s.append(""); + return writeFile(getBareJidFile(item.getUser()), s.toString()); + } + + + private File getBareJidFile(String bareJid) { + String encodedJid = Base32Encoder.getInstance().encode(bareJid); + return new File(fileDir, ENTRY_PREFIX + encodedJid); + } + + private String readFile(File file) { + try { + Reader reader = null; + try { + char buf[] = new char[8192]; + int len; + StringBuilder s = new StringBuilder(); + reader = new FileReader(file); + while ((len = reader.read(buf)) >= 0) { + s.append(buf, 0, len); + } + return s.toString(); + } + finally { + if (reader != null) { + reader.close(); + } + } + } + catch (FileNotFoundException e) { + return null; + } + catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + private boolean writeFile(File file, String content) { + try { + FileWriter writer = new FileWriter(file, false); + writer.write(content); + writer.close(); + return true; + } + catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + private void log(String error) { + System.err.println(error); + } +} diff --git a/source/org/jivesoftware/smack/PacketReader.java b/source/org/jivesoftware/smack/PacketReader.java index a00f53c5a..b2d58510d 100644 --- a/source/org/jivesoftware/smack/PacketReader.java +++ b/source/org/jivesoftware/smack/PacketReader.java @@ -396,6 +396,11 @@ class PacketReader { // The server supports sessions connection.getSASLAuthentication().sessionsSupported(); } + else if (parser.getName().equals("ver")) { + if (parser.getNamespace().equals("urn:xmpp:features:rosterver")) { + connection.setRosterVersioningSupported(); + } + } else if (parser.getName().equals("compression")) { // The server supports stream compression connection.setAvailableCompressionMethods(PacketParserUtils.parseCompressionMethods(parser)); diff --git a/source/org/jivesoftware/smack/Roster.java b/source/org/jivesoftware/smack/Roster.java index b56afac1a..0ed2dc278 100644 --- a/source/org/jivesoftware/smack/Roster.java +++ b/source/org/jivesoftware/smack/Roster.java @@ -27,9 +27,11 @@ import org.jivesoftware.smack.packet.IQ; import org.jivesoftware.smack.packet.Packet; import org.jivesoftware.smack.packet.Presence; import org.jivesoftware.smack.packet.RosterPacket; +import org.jivesoftware.smack.packet.RosterPacket.Item; import org.jivesoftware.smack.util.StringUtils; import java.util.*; +import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -55,7 +57,8 @@ public class Roster { */ private static SubscriptionMode defaultSubscriptionMode = SubscriptionMode.accept_all; - private Connection connection; + private final Connection connection; + private final RosterStore rosterStore; private final Map groups; private final Map entries; private final List unfiledEntries; @@ -99,6 +102,7 @@ public class Roster { */ Roster(final Connection connection) { this.connection = connection; + rosterStore = connection.getConfiguration().getRosterStore(); groups = new ConcurrentHashMap(); unfiledEntries = new CopyOnWriteArrayList(); entries = new ConcurrentHashMap(); @@ -188,7 +192,13 @@ public class Roster { throw new IllegalStateException("Anonymous users can't have a roster."); } - connection.sendPacket(new RosterPacket()); + RosterPacket packet = new RosterPacket(); + if (rosterStore != null && connection.isRosterVersioningSupported()) { + packet.setVersion(rosterStore.getRosterVersion()); + PacketFilter filter = new PacketIDFilter(packet.getPacketID()); + connection.addPacketListener(new RosterResultListener(), filter); + } + connection.sendPacket(packet); } /** @@ -660,6 +670,81 @@ public class Roster { } } + private void addUpdateEntry(Collection addedEntries, + Collection updatedEntries, RosterPacket.Item item, + RosterEntry entry) { + RosterEntry oldEntry = entries.put(item.getUser(), entry); + if (oldEntry == null) { + addedEntries.add(item.getUser()); + } + else { + RosterPacket.Item oldItem = RosterEntry.toRosterItem(oldEntry); + if (!oldEntry.equalsDeep(entry) || !item.getGroupNames().equals(oldItem.getGroupNames())) { + updatedEntries.add(item.getUser()); + } + } + + // Mark the entry as unfiled if it does not belong to any groups. + if (item.getGroupNames().isEmpty()) { + if (!unfiledEntries.contains(entry)) { + unfiledEntries.add(entry); + } + } + else { + unfiledEntries.remove(entry); + } + + // Add the user to the new groups + + // Add the entry to the groups + List newGroupNames = new ArrayList(); + for (String groupName : item.getGroupNames()) { + // Add the group name to the list. + newGroupNames.add(groupName); + + // Add the entry to the group. + RosterGroup group = getGroup(groupName); + if (group == null) { + group = createGroup(groupName); + groups.put(groupName, group); + } + // Add the entry. + group.addEntryLocal(entry); + } + + // Remove user from the remaining groups. + List oldGroupNames = new ArrayList(); + for (RosterGroup group: getGroups()) { + oldGroupNames.add(group.getName()); + } + oldGroupNames.removeAll(newGroupNames); + + for (String groupName : oldGroupNames) { + RosterGroup group = getGroup(groupName); + group.removeEntryLocal(entry); + if (group.getEntryCount() == 0) { + groups.remove(groupName); + } + } + } + + private void deleteEntry(Collection deletedEntries, RosterEntry entry) { + String user = entry.getUser(); + entries.remove(user); + unfiledEntries.remove(entry); + presenceMap.remove(StringUtils.parseBareAddress(user)); + deletedEntries.add(user); + + for (Entry e: groups.entrySet()) { + RosterGroup group = e.getValue(); + group.removeEntryLocal(entry); + if (group.getEntryCount() == 0) { + groups.remove(e.getKey()); + } + } + } + + /** * An enumeration for the subscription mode options. */ @@ -801,123 +886,98 @@ public class Roster { } } + /** + * Handles the case of the empty IQ-result for roster versioning. + * + * Intended to listen for a concrete roster result and deregisters + * itself after a processed packet. + */ + private class RosterResultListener implements PacketListener { + + @Override + public void processPacket(Packet packet) { + connection.removePacketListener(this); + if (packet instanceof RosterPacket) { + // Non-empty roster results are processed by the RosterPacketListener class + return; + } + if (!(packet instanceof IQ)) { + return; + } + IQ result = (IQ)packet; + if(result.getType().equals(IQ.Type.RESULT)){ + Collection addedEntries = new ArrayList(); + Collection updatedEntries = new ArrayList(); + for(RosterPacket.Item item : rosterStore.getEntries()){ + RosterEntry entry = new RosterEntry(item.getUser(), item.getName(), + item.getItemType(), item.getItemStatus(), Roster.this, connection); + addUpdateEntry(addedEntries,updatedEntries,item,entry); + } + + synchronized (Roster.this) { + rosterInitialized = true; + Roster.this.notifyAll(); + } + fireRosterChangedEvent(addedEntries,updatedEntries, + Collections.emptyList()); + } + } + + } + /** * Listens for all roster packets and processes them. */ private class RosterPacketListener implements PacketListener { public void processPacket(Packet packet) { - // Keep a registry of the entries that were added, deleted or updated. An event - // will be fired for each affected entry + RosterPacket rosterPacket = (RosterPacket) packet; Collection addedEntries = new ArrayList(); Collection updatedEntries = new ArrayList(); Collection deletedEntries = new ArrayList(); - RosterPacket rosterPacket = (RosterPacket) packet; - for (RosterPacket.Item item : rosterPacket.getRosterItems()) { - RosterEntry entry = new RosterEntry(item.getUser(), item.getName(), - item.getItemType(), item.getItemStatus(), Roster.this, connection); + String version = rosterPacket.getVersion(); - // If the packet is of the type REMOVE then remove the entry - if (RosterPacket.ItemType.remove.equals(item.getItemType())) { - // Remove the entry from the entry list. - if (entries.containsKey(item.getUser())) { - entries.remove(item.getUser()); - } - // Remove the entry from the unfiled entry list. - if (unfiledEntries.contains(entry)) { - unfiledEntries.remove(entry); - } - // Removing the user from the roster, so remove any presence information - // about them. - String key = StringUtils.parseName(item.getUser()) + "@" + - StringUtils.parseServer(item.getUser()); - presenceMap.remove(key); - // Keep note that an entry has been removed - deletedEntries.add(item.getUser()); + if (rosterPacket.getType().equals(IQ.Type.SET)) { + // Roster push (RFC 6121, 2.1.6) + // A roster push with a non-empty from not matching our address MUST be ignored + String jid = StringUtils.parseBareAddress(connection.getUser()); + if (rosterPacket.getFrom() != null && + !rosterPacket.getFrom().equals(jid)) { + return; } - else { - // Make sure the entry is in the entry list. - if (!entries.containsKey(item.getUser())) { - entries.put(item.getUser(), entry); - // Keep note that an entry has been added - addedEntries.add(item.getUser()); - } - else { - // If the entry was in then list then update its state with the new values - RosterEntry oldEntry = entries.put(item.getUser(), entry); - RosterPacket.Item oldItem = RosterEntry.toRosterItem(oldEntry); - //We have also to check if only the group names have changed from the item - if (oldEntry == null || !oldEntry.equalsDeep(entry) || !item.getGroupNames().equals(oldItem.getGroupNames())) - { - updatedEntries.add(item.getUser()); - } - } - // If the roster entry belongs to any groups, remove it from the - // list of unfiled entries. - if (!item.getGroupNames().isEmpty()) { - unfiledEntries.remove(entry); - } - // Otherwise add it to the list of unfiled entries. - else { - if (!unfiledEntries.contains(entry)) { - unfiledEntries.add(entry); - } + // A roster push must contain exactly one entry + Collection items = rosterPacket.getRosterItems(); + if (items.size() != 1) { + return; + } + Item item = items.iterator().next(); + processPushItem(addedEntries, updatedEntries, deletedEntries, version, item); + + connection.sendPacket(IQ.createResultIQ(rosterPacket)); + } + else { + // Roster result (RFC 6121, 2.1.4) + + // Ignore items without valid subscription type + ArrayList validItems = new ArrayList(); + for (RosterPacket.Item item : rosterPacket.getRosterItems()) { + if (hasValidSubscriptionType(item)) { + validItems.add(item); } } - // Find the list of groups that the user currently belongs to. - List currentGroupNames = new ArrayList(); - for (RosterGroup group: getGroups()) { - if (group.contains(entry)) { - currentGroupNames.add(group.getName()); - } - } + processResult(addedEntries, updatedEntries, deletedEntries, version, validItems); + } - // If the packet is not of the type REMOVE then add the entry to the groups - if (!RosterPacket.ItemType.remove.equals(item.getItemType())) { - // Create the new list of groups the user belongs to. - List newGroupNames = new ArrayList(); - for (String groupName : item.getGroupNames()) { - // Add the group name to the list. - newGroupNames.add(groupName); - - // Add the entry to the group. - RosterGroup group = getGroup(groupName); - if (group == null) { - group = createGroup(groupName); - groups.put(groupName, group); - } - // Add the entry. - group.addEntryLocal(entry); - } - - // We have the list of old and new group names. We now need to - // remove the entry from the all the groups it may no longer belong - // to. We do this by subtracting the new group set from the old. - for (String newGroupName : newGroupNames) { - currentGroupNames.remove(newGroupName); - } - } - - // Loop through any groups that remain and remove the entries. - // This is necessary for the case of remote entry removals. - for (String groupName : currentGroupNames) { - RosterGroup group = getGroup(groupName); - group.removeEntryLocal(entry); - if (group.getEntryCount() == 0) { - groups.remove(groupName); - } - } - // Remove all the groups with no entries. We have to do this because - // RosterGroup.removeEntry removes the entry immediately (locally) and the - // group could remain empty. - // TODO Check the performance/logic for rosters with large number of groups - for (RosterGroup group : getGroups()) { - if (group.getEntryCount() == 0) { - groups.remove(group.getName()); - } + // Remove all the groups with no entries. We have to do this because + // RosterGroup.removeEntry removes the entry immediately (locally) and the + // group could remain empty. + // TODO Check the performance/logic for rosters with large number of groups + for (RosterGroup group : getGroups()) { + if (group.getEntryCount() == 0) { + groups.remove(group.getName()); } } @@ -930,5 +990,58 @@ public class Roster { // Fire event for roster listeners. fireRosterChangedEvent(addedEntries, updatedEntries, deletedEntries); } + + private void processPushItem(Collection addedEntries, Collection updatedEntries, + Collection deletedEntries, String version, Item item) { + RosterEntry entry = new RosterEntry(item.getUser(), item.getName(), + item.getItemType(), item.getItemStatus(), Roster.this, connection); + + if (item.getItemType().equals(RosterPacket.ItemType.remove)) { + deleteEntry(deletedEntries, entry); + if (rosterStore != null) { + rosterStore.removeEntry(entry.getUser(), version); + } + } + else if (hasValidSubscriptionType(item)) { + addUpdateEntry(addedEntries, updatedEntries, item, entry); + if (rosterStore != null) { + rosterStore.addEntry(item, version); + } + } + } + + private void processResult(Collection addedEntries, Collection updatedEntries, + Collection deletedEntries, String version, Collection items) { + for (RosterPacket.Item item : items) { + RosterEntry entry = new RosterEntry(item.getUser(), item.getName(), + item.getItemType(), item.getItemStatus(), Roster.this, connection); + addUpdateEntry(addedEntries, updatedEntries, item, entry); + } + List toDelete = new ArrayList(); + + // Delete all entries which where not added or updated + for (RosterEntry entry : entries.values()) { + toDelete.add(entry.getUser()); + } + toDelete.removeAll(addedEntries); + toDelete.removeAll(updatedEntries); + for (String user : toDelete) { + deleteEntry(deletedEntries, entries.get(user)); + } + + if (rosterStore != null) { + rosterStore.resetEntries(items, version); + } + } + + /* Ignore ItemTypes as of RFC 6121, 2.1.2.5. */ + private boolean hasValidSubscriptionType(RosterPacket.Item item) { + return item.getItemType().equals(RosterPacket.ItemType.none) + || item.getItemType().equals(RosterPacket.ItemType.from) + || item.getItemType().equals(RosterPacket.ItemType.to) + || item.getItemType().equals(RosterPacket.ItemType.both); + } + } + } diff --git a/source/org/jivesoftware/smack/RosterEntry.java b/source/org/jivesoftware/smack/RosterEntry.java index 55b394ed7..3796c3ed6 100644 --- a/source/org/jivesoftware/smack/RosterEntry.java +++ b/source/org/jivesoftware/smack/RosterEntry.java @@ -169,6 +169,11 @@ public class RosterEntry { return buf.toString(); } + @Override + public int hashCode() { + return (user == null ? 0 : user.hashCode()); + } + public boolean equals(Object object) { if (this == object) { return true; @@ -181,11 +186,6 @@ public class RosterEntry { } } - @Override - public int hashCode() { - return this.user.hashCode(); - } - /** * Indicates whether some other object is "equal to" this by comparing all members. *

diff --git a/source/org/jivesoftware/smack/RosterGroup.java b/source/org/jivesoftware/smack/RosterGroup.java index 80e6438bf..dfd326461 100644 --- a/source/org/jivesoftware/smack/RosterGroup.java +++ b/source/org/jivesoftware/smack/RosterGroup.java @@ -28,7 +28,8 @@ import org.jivesoftware.smack.util.StringUtils; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.List; +import java.util.LinkedHashSet; +import java.util.Set; /** * A group of roster entries. @@ -40,7 +41,7 @@ public class RosterGroup { private String name; private Connection connection; - private final List entries; + private final Set entries; /** * Creates a new roster group instance. @@ -51,7 +52,7 @@ public class RosterGroup { RosterGroup(String name, Connection connection) { this.name = name; this.connection = connection; - entries = new ArrayList(); + entries = new LinkedHashSet(); } /** @@ -235,7 +236,7 @@ public class RosterGroup { } void addEntryLocal(RosterEntry entry) { - // Only add the entry if it isn't already in the list. + // Update the entry if it is already in the list synchronized (entries) { entries.remove(entry); entries.add(entry); diff --git a/source/org/jivesoftware/smack/RosterStore.java b/source/org/jivesoftware/smack/RosterStore.java new file mode 100644 index 000000000..d6627b95d --- /dev/null +++ b/source/org/jivesoftware/smack/RosterStore.java @@ -0,0 +1,67 @@ +/** + * 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.smack; + +import java.util.Collection; + +import org.jivesoftware.smack.packet.RosterPacket; + +/** + * This is an interface for persistent roster store needed to implement + * roster versioning as per RFC 6121. + */ + +public interface RosterStore { + + /** + * This method returns a collection of all roster items contained in this store. + * @return List of {@link RosterEntry} + */ + public Collection getEntries(); + /** + * This method returns the roster item in this store for the given JID. + * @param bareJid The bare JID of the RosterEntry + * @return The {@link RosterEntry} which belongs to that user + */ + public RosterPacket.Item getEntry(String bareJid); + /** + * This method returns the version number as specified by the "ver" attribute + * of the local store. For a fresh store, this MUST be the empty string. + * @return local roster version + */ + public String getRosterVersion(); + /** + * This method stores a new roster entry in this store or updates an existing one. + * @param item the entry to store + * @param version the new roster version + * @return True if successful + */ + public boolean addEntry(RosterPacket.Item item, String version); + /** + * This method updates the store so that it contains only the given entries. + * @param items the entries to store + * @param version the new roster version + * @return True if successful + */ + public boolean resetEntries(Collection items, String version); + /** + * Removes an entry from the store + * @param bareJid The bare JID of the entry to be removed + * @param version the new roster version + * @return True if successful + */ + public boolean removeEntry(String bareJid, String version); + +} diff --git a/source/org/jivesoftware/smack/packet/RosterPacket.java b/source/org/jivesoftware/smack/packet/RosterPacket.java index 9360480af..295d5363d 100644 --- a/source/org/jivesoftware/smack/packet/RosterPacket.java +++ b/source/org/jivesoftware/smack/packet/RosterPacket.java @@ -33,6 +33,7 @@ import java.util.concurrent.CopyOnWriteArraySet; public class RosterPacket extends IQ { private final List rosterItems = new ArrayList(); + private String rosterVersion; /** * Adds a roster item to the packet. @@ -69,7 +70,13 @@ public class RosterPacket extends IQ { public String getChildElementXML() { StringBuilder buf = new StringBuilder(); - buf.append(""); + buf.append(""); synchronized (rosterItems) { for (Item entry : rosterItems) { buf.append(entry.toXML()); @@ -79,6 +86,14 @@ public class RosterPacket extends IQ { return buf.toString(); } + public String getVersion() { + return rosterVersion; + } + + public void setVersion(String version) { + rosterVersion = version; + } + /** * A roster item, which consists of a JID, their name, the type of subscription, and * the groups the roster item belongs to. @@ -198,7 +213,7 @@ public class RosterPacket extends IQ { public String toXML() { StringBuilder buf = new StringBuilder(); - buf.append(""); return buf.toString(); } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((groupNames == null) ? 0 : groupNames.hashCode()); + result = prime * result + ((itemStatus == null) ? 0 : itemStatus.hashCode()); + result = prime * result + ((itemType == null) ? 0 : itemType.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + result = prime * result + ((user == null) ? 0 : user.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Item other = (Item) obj; + if (groupNames == null) { + if (other.groupNames != null) + return false; + } + else if (!groupNames.equals(other.groupNames)) + return false; + if (itemStatus != other.itemStatus) + return false; + if (itemType != other.itemType) + return false; + if (name == null) { + if (other.name != null) + return false; + } + else if (!name.equals(other.name)) + return false; + if (user == null) { + if (other.user != null) + return false; + } + else if (!user.equals(other.user)) + return false; + return true; + } + } /** * The subscription status of a roster item. An optional element that indicates * the subscription status if a change request is pending. */ - public static class ItemStatus { + public static enum ItemStatus { + /** + * Request to subscribe + */ + subscribe, /** - * Request to subcribe. + * Request to unsubscribe */ - public static final ItemStatus SUBSCRIPTION_PENDING = new ItemStatus("subscribe"); + unsubscribe; - /** - * Request to unsubscribe. - */ - public static final ItemStatus UNSUBSCRIPTION_PENDING = new ItemStatus("unsubscribe"); + public static final ItemStatus SUBSCRIPTION_PENDING = subscribe; + public static final ItemStatus UNSUBSCRIPTION_PENDING = unsubscribe; - public static ItemStatus fromString(String value) { - if (value == null) { + public static ItemStatus fromString(String s) { + if (s == null) { return null; } - value = value.toLowerCase(); - if ("unsubscribe".equals(value)) { - return UNSUBSCRIPTION_PENDING; + try { + return ItemStatus.valueOf(s); } - else if ("subscribe".equals(value)) { - return SUBSCRIPTION_PENDING; - } - else { + catch (IllegalArgumentException e) { return null; } } - - private String value; - - /** - * Returns the item status associated with the specified string. - * - * @param value the item status. - */ - private ItemStatus(String value) { - this.value = value; - } - - public String toString() { - return value; - } } public static enum ItemType { diff --git a/source/org/jivesoftware/smack/util/PacketParserUtils.java b/source/org/jivesoftware/smack/util/PacketParserUtils.java index a8d6b9e36..44bac60b8 100644 --- a/source/org/jivesoftware/smack/util/PacketParserUtils.java +++ b/source/org/jivesoftware/smack/util/PacketParserUtils.java @@ -419,6 +419,10 @@ public class PacketParserUtils { RosterPacket roster = new RosterPacket(); boolean done = false; RosterPacket.Item item = null; + + String version = parser.getAttributeValue("", "ver"); + roster.setVersion(version); + while (!done) { int eventType = parser.next(); if (eventType == XmlPullParser.START_TAG) { @@ -436,7 +440,7 @@ public class PacketParserUtils { RosterPacket.ItemType type = RosterPacket.ItemType.valueOf(subscription != null ? subscription : "none"); item.setItemType(type); } - if (parser.getName().equals("group") && item!= null) { + else if (parser.getName().equals("group") && item!= null) { final String groupName = parser.nextText(); if (groupName != null && groupName.trim().length() > 0) { item.addGroupName(groupName); diff --git a/source/org/jivesoftware/smack/util/StringUtils.java b/source/org/jivesoftware/smack/util/StringUtils.java index eecd84dda..915c98082 100644 --- a/source/org/jivesoftware/smack/util/StringUtils.java +++ b/source/org/jivesoftware/smack/util/StringUtils.java @@ -520,10 +520,52 @@ public class StringUtils { return buf.toString(); } + /** + * Encodes a string for use in an XML attribute by escaping characters with + * a special meaning. In particular, white spaces are encoded as character + * references, such that they are not replaced by ' ' on parsing. + */ + private static String xmlAttribEncodeBinary(String value) { + StringBuilder s = new StringBuilder(); + char buf[] = value.toCharArray(); + for (char c : buf) { + switch (c) { + case '<': s.append("<"); break; + case '>': s.append(">"); break; + case '&': s.append("&"); break; + case '"': s.append("""); break; + case '\'': s.append("'"); break; + default: + if (c <= 0x1f || (0x7f <= c && c <= 0x9f)) { // includes \t, \n, \r + s.append("&#x"); + s.append(String.format("%X", (int)c)); + s.append(';'); + } else { + s.append(c); + } + } + } + return s.toString(); + } + + /** + * Returns a string representing a XML attribute. The value parameter is escaped as necessary. In particular, + * white spaces are encoded as character references, such that they are not replaced by ' ' on parsing. + * @param name name of the XML attribute + * @param value value of the XML attribute + */ + public static String xmlAttrib(String name, String value) { + return name + "=\"" + xmlAttribEncodeBinary(value) + "\""; + } + + /** * Escapes all necessary characters in the String so that it can be used * in an XML doc. * + * Warning: This method does not escape unicode character references + * (i.e. references of the from ë) + * * @param string the string to escape. * @return the string with appropriate characters escaped. */ diff --git a/test-unit/org/jivesoftware/smack/DefaultRosterStoreTest.java b/test-unit/org/jivesoftware/smack/DefaultRosterStoreTest.java new file mode 100644 index 000000000..81dfad2d2 --- /dev/null +++ b/test-unit/org/jivesoftware/smack/DefaultRosterStoreTest.java @@ -0,0 +1,222 @@ +/** + * 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.smack; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.jivesoftware.smack.packet.RosterPacket; +import org.jivesoftware.smack.packet.RosterPacket.Item; +import org.jivesoftware.smack.packet.RosterPacket.ItemStatus; +import org.jivesoftware.smack.packet.RosterPacket.ItemType; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests the implementation of {@link DefaultRosterStore}. + * + * @author Lars Noschinski + */ +public class DefaultRosterStoreTest { + + @Rule + public TemporaryFolder tmpFolder = new TemporaryFolder(); + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + } + + /** + * Tests that opening an uninitialized directory fails. + */ + @Test + public void testStoreUninitialized() throws IOException { + File storeDir = tmpFolder.newFolder(); + assertNull(DefaultRosterStore.open(storeDir)); + } + + /** + * Tests that an initialized directory is empty. + */ + @Test + public void testStoreInitializedEmpty() throws IOException { + File storeDir = tmpFolder.newFolder(); + DefaultRosterStore store = DefaultRosterStore.init(storeDir); + assertNotNull("Initialization returns store", store); + assertEquals("Freshly initialized store must have empty version", + "", store.getRosterVersion()); + assertEquals("Freshly initialized store must have no entries", + 0, store.getEntries().size()); + } + + /** + * Tests adding and removing entries + */ + @Test + public void testStoreAddRemove() throws IOException { + File storeDir = tmpFolder.newFolder(); + DefaultRosterStore store = DefaultRosterStore.init(storeDir); + + assertEquals("Initial roster version", "", store.getRosterVersion()); + + String userName = "user@example.com"; + + final RosterPacket.Item item1 = new Item(userName, null); + final String version1 = "1"; + store.addEntry(item1, version1); + + assertEquals("Adding entry sets version correctly", version1, store.getRosterVersion()); + + RosterPacket.Item storedItem = store.getEntry(userName); + assertNotNull("Added entry not found found", storedItem); + assertEquals("User of added entry", + item1.getUser(), storedItem.getUser()); + assertEquals("Name of added entry", + item1.getName(), storedItem.getName()); + assertEquals("Groups", item1.getGroupNames(), storedItem.getGroupNames()); + assertEquals("ItemType of added entry", + item1.getItemType(), storedItem.getItemType()); + assertEquals("ItemStatus of added entry", + item1.getItemStatus(), storedItem.getItemStatus()); + + + final String version2 = "2"; + final RosterPacket.Item item2 = new Item(userName, "Ursula Example"); + item2.addGroupName("users"); + item2.addGroupName("examples"); + item2.setItemStatus(ItemStatus.subscribe); + item2.setItemType(ItemType.none); + store.addEntry(item2,version2); + assertEquals("Updating entry sets version correctly", version2, store.getRosterVersion()); + storedItem = store.getEntry(userName); + assertNotNull("Added entry not found", storedItem); + assertEquals("User of added entry", + item2.getUser(), storedItem.getUser()); + assertEquals("Name of added entry", + item2.getName(), storedItem.getName()); + assertEquals("Groups", item2.getGroupNames(), storedItem.getGroupNames()); + assertEquals("ItemType of added entry", + item2.getItemType(), storedItem.getItemType()); + assertEquals("ItemStatus of added entry", + item2.getItemStatus(), storedItem.getItemStatus()); + + List entries = store.getEntries(); + assertEquals("Number of entries", 1, entries.size()); + + final RosterPacket.Item item3 = new Item("foobar@example.com", "Foo Bar"); + item3.addGroupName("The Foo Fighters"); + item3.addGroupName("Bar Friends"); + item3.setItemStatus(ItemStatus.unsubscribe); + item3.setItemType(ItemType.both); + + final RosterPacket.Item item4 = new Item("baz@example.com", "Baba Baz"); + item4.addGroupName("The Foo Fighters"); + item4.addGroupName("Bar Friends"); + item4.setItemStatus(ItemStatus.subscribe); + item4.setItemType(ItemType.both); + + ArrayList items34 = new ArrayList(); + items34.add(item3); + items34.add(item4); + + String version3 = "3"; + store.resetEntries(items34, version3); + + storedItem = store.getEntry("foobar@example.com"); + assertNotNull("Added entry not found", storedItem); + assertEquals("User of added entry", + item3.getUser(), storedItem.getUser()); + assertEquals("Name of added entry", + item3.getName(), storedItem.getName()); + assertEquals("Groups", item3.getGroupNames(), storedItem.getGroupNames()); + assertEquals("ItemType of added entry", + item3.getItemType(), storedItem.getItemType()); + assertEquals("ItemStatus of added entry", + item3.getItemStatus(), storedItem.getItemStatus()); + + + storedItem = store.getEntry("baz@example.com"); + assertNotNull("Added entry not found", storedItem); + assertEquals("User of added entry", + item4.getUser(), storedItem.getUser()); + assertEquals("Name of added entry", + item4.getName(), storedItem.getName()); + assertEquals("Groups", item4.getGroupNames(), storedItem.getGroupNames()); + assertEquals("ItemType of added entry", + item4.getItemType(), storedItem.getItemType()); + assertEquals("ItemStatus of added entry", + item4.getItemStatus(), storedItem.getItemStatus()); + + entries = store.getEntries(); + assertEquals("Number of entries", 2, entries.size()); + + String version4 = "4"; + store.removeEntry("baz@example.com", version4); + assertEquals("Removing entry sets version correctly", + version4, store.getRosterVersion()); + assertNull("Removed entry is gone", store.getEntry(userName)); + + entries = store.getEntries(); + assertEquals("Number of entries", 1, entries.size()); + } + + /** + * Tests adding entries with evil characters + */ + @Test + public void testAddEvilChars() throws IOException { + File storeDir = tmpFolder.newFolder(); + DefaultRosterStore store = DefaultRosterStore.init(storeDir); + + String user = "../_#;\"'\\&@example.com"; + String name = "\n../_#\0\t;\"'&@\\"; + String group1 = "\t;\"'&@\\\n../_#\0"; + String group2 = "#\0\t;\"'&@\\\n../_"; + + Item item = new Item(user, name); + item.setItemStatus(ItemStatus.unsubscribe); + item.setItemType(ItemType.to); + item.addGroupName(group1); + item.addGroupName(group2); + store.addEntry(item, "a-version"); + Item storedItem = store.getEntry(user); + + assertNotNull("Added entry not found", storedItem); + assertEquals("User of added entry", + item.getUser(), storedItem.getUser()); + assertEquals("Name of added entry", + item.getName(), storedItem.getName()); + assertEquals("Groups", item.getGroupNames(), storedItem.getGroupNames()); + assertEquals("ItemType of added entry", + item.getItemType(), storedItem.getItemType()); + assertEquals("ItemStatus of added entry", + item.getItemStatus(), storedItem.getItemStatus()); + + } +} + diff --git a/test-unit/org/jivesoftware/smack/RosterTest.java b/test-unit/org/jivesoftware/smack/RosterTest.java index c65a213ec..f2e9f1d86 100644 --- a/test-unit/org/jivesoftware/smack/RosterTest.java +++ b/test-unit/org/jivesoftware/smack/RosterTest.java @@ -355,6 +355,25 @@ public class RosterTest { assertSame("Wrong number of roster entries.", 4, roster.getEntries().size()); } + /** + * Tests that roster pushes with invalid from are ignored. + * + * @see RFC 6121, Section 2.1.6 + */ + @Test(timeout=5000) + public void testIgnoreInvalidFrom() { + RosterPacket packet = new RosterPacket(); + packet.setType(Type.SET); + packet.setTo(connection.getUser()); + packet.setFrom("mallory@example.com"); + packet.addRosterItem(new Item("spam@example.com", "Cool products!")); + + // Simulate receiving the roster push + connection.processPacket(packet); + + assertNull("Contact was added to roster", connection.getRoster().getEntry("spam@example.com")); + } + /** * Test if adding an user with an empty group is equivalent with providing * no group. diff --git a/test-unit/org/jivesoftware/smack/RosterVersioningTest.java b/test-unit/org/jivesoftware/smack/RosterVersioningTest.java new file mode 100644 index 000000000..797c4b2d9 --- /dev/null +++ b/test-unit/org/jivesoftware/smack/RosterVersioningTest.java @@ -0,0 +1,242 @@ +/** + * 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.smack; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; + +import org.jivesoftware.smack.packet.IQ; +import org.jivesoftware.smack.packet.IQ.Type; +import org.jivesoftware.smack.packet.Packet; +import org.jivesoftware.smack.packet.RosterPacket; +import org.jivesoftware.smack.packet.RosterPacket.Item; +import org.jivesoftware.smack.packet.RosterPacket.ItemType; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests that verify the correct behavior of the {@see Roster} implementation + * with regard to roster versioning + * + * @see Roster + * @see Managing the Roster + * @author Fabian Schuetz + * @author Lars Noschinski + */ +public class RosterVersioningTest { + + private DummyConnection connection; + + @Rule + public TemporaryFolder tmpFolder = new TemporaryFolder(); + + @Before + public void setUp() throws Exception { + // Uncomment this to enable debug output + //Connection.DEBUG_ENABLED = true; + + DefaultRosterStore store = DefaultRosterStore.init(tmpFolder.newFolder("store")); + populateStore(store); + + ConnectionConfiguration conf = new ConnectionConfiguration("dummy"); + conf.setRosterStore(store); + connection = new DummyConnection(conf); + connection.connect(); + + connection.setRosterVersioningSupported(); + + connection.login("rostertest", "secret"); + } + + @After + public void tearDown() throws Exception { + if (connection != null) { + connection.disconnect(); + connection = null; + } + } + + /** + * Tests that receiving an empty roster result causes the roster to be populated + * by all entries of the roster store. + */ + @Test(timeout = 5000) + public void testEqualVersionStored() throws InterruptedException, IOException { + connection.getRoster().reload(); + answerWithEmptyRosterResult(); + + Roster roster = connection.getRoster(); + Collection entries = roster.getEntries(); + assertSame("Size of the roster", 3, entries.size()); + + HashSet items = new HashSet(); + for (RosterEntry entry : entries) { + items.add(RosterEntry.toRosterItem(entry)); + } + RosterStore store = DefaultRosterStore.init(tmpFolder.newFolder()); + populateStore(store); + assertEquals("Elements of the roster", new HashSet(store.getEntries()), items); + + for (RosterEntry entry : entries) { + assertTrue("joe stevens".equals(entry.getName()) || "geoff hurley".equals(entry.getName()) + || "higgins mcmann".equals(entry.getName())); + } + Collection groups = roster.getGroups(); + assertSame(3, groups.size()); + + for (RosterGroup group : groups) { + assertTrue("all".equals(group.getName()) || "friends".equals(group.getName()) + || "partners".equals(group.getName())); + } + } + + /** + * Tests that a non-empty roster result empties the store. + */ + @Test(timeout = 5000) + public void testOtherVersionStored() throws InterruptedException { + connection.getRoster().reload(); + + Item vaglafItem = vaglafItem(); + + // We expect that the roster request is the only packet sent. This is not part of the specification, + // but a shortcut in the test implementation. + Packet sentPacket = connection.getSentPacket(); + if (sentPacket instanceof RosterPacket) { + RosterPacket sentRP = (RosterPacket)sentPacket; + RosterPacket answer = new RosterPacket(); + answer.setPacketID(sentRP.getPacketID()); + answer.setType(Type.RESULT); + answer.setTo(sentRP.getFrom()); + + answer.setVersion("newVersion"); + answer.addRosterItem(vaglafItem); + + connection.processPacket(answer); + } else { + assertTrue("Expected to get a RosterPacket ", false); + } + + Roster roster = connection.getRoster(); + assertEquals("Size of roster", 1, roster.getEntries().size()); + RosterEntry entry = roster.getEntry(vaglafItem.getUser()); + assertNotNull("Roster contains vaglaf entry", entry); + assertEquals("vaglaf entry in roster equals the sent entry", vaglafItem, RosterEntry.toRosterItem(entry)); + + RosterStore store = connection.getConfiguration().getRosterStore(); + assertEquals("Size of store", 1, store.getEntries().size()); + Item item = store.getEntry(vaglafItem.getUser()); + assertNotNull("Store contains vaglaf entry"); + assertEquals("vaglaf entry in store equals the sent entry", vaglafItem, item); + } + + /** + * Test roster versioning with roster pushes + */ + @Test(timeout = 5000) + public void testRosterVersioningWithCachedRosterAndPushes() throws Throwable { + connection.getRoster().reload(); + answerWithEmptyRosterResult(); + + RosterStore store = connection.getConfiguration().getRosterStore(); + Roster roster = connection.getRoster(); + + // Simulate a roster push adding vaglaf + { + RosterPacket rosterPush = new RosterPacket(); + rosterPush.setTo("rostertest@example.com/home"); + rosterPush.setType(Type.SET); + rosterPush.setVersion("v97"); + + Item pushedItem = vaglafItem(); + rosterPush.addRosterItem(pushedItem); + connection.processPacket(rosterPush); + + assertEquals("Expect store version after push", "v97", store.getRosterVersion()); + + Item storedItem = store.getEntry("vaglaf@example.com"); + assertNotNull("Expect vaglaf to be added", storedItem); + assertEquals("Expect vaglaf to be equal to pushed item", pushedItem, storedItem); + + Collection rosterItems = new HashSet(); + for (RosterEntry entry : roster.getEntries()) { + rosterItems.add(RosterEntry.toRosterItem(entry)); + } + assertEquals(rosterItems, new HashSet(store.getEntries())); + } + + // Simulate a roster push removing vaglaf + { + RosterPacket rosterPush = new RosterPacket(); + rosterPush.setTo("rostertest@example.com/home"); + rosterPush.setType(Type.SET); + rosterPush.setVersion("v98"); + + Item item = new Item("vaglaf@example.com", "vaglaf the only"); + item.setItemType(ItemType.remove); + rosterPush.addRosterItem(item); + connection.processPacket(rosterPush); + + assertNull("Store doses not contain vaglaf", store.getEntry("vaglaf@example.com")); + assertEquals("Expect store version after push", "v98", store.getRosterVersion()); + } + } + + private Item vaglafItem() { + Item item = new Item("vaglaf@example.com", "vaglaf the only"); + item.setItemType(ItemType.both); + item.addGroupName("all"); + item.addGroupName("friends"); + item.addGroupName("partners"); + return item; + } + + private void populateStore(RosterStore store) throws IOException { + store.addEntry(new RosterPacket.Item("geoff@example.com", "geoff hurley"), ""); + + RosterPacket.Item item = new RosterPacket.Item("joe@example.com", "joe stevens"); + item.addGroupName("friends"); + item.addGroupName("partners"); + store.addEntry(item, ""); + + item = new RosterPacket.Item("higgins@example.com", "higgins mcmann"); + item.addGroupName("all"); + item.addGroupName("friends"); + store.addEntry(item, "v96"); + } + + private void answerWithEmptyRosterResult() throws InterruptedException { + // We expect that the roster request is the only packet sent. This is not part of the specification, + // but a shortcut in the test implementation. + Packet sentPacket = connection.getSentPacket(); + if (sentPacket instanceof RosterPacket) { + final IQ emptyIQ = IQ.createResultIQ((RosterPacket)sentPacket); + connection.processPacket(emptyIQ); + } else { + assertTrue("Expected to get a RosterPacket ", false); + } + } + +}