Added support for pre-approved subscription requests (RFC 6121 § 3.4)

SMACK-639
This commit is contained in:
Tomáš Havlas 2015-03-14 15:19:52 +01:00 committed by Florian Schmaus
parent f410ece468
commit fae9d129a9
12 changed files with 440 additions and 5 deletions

View File

@ -1405,7 +1405,7 @@ public abstract class AbstractXMPPConnection implements XMPPConnection {
return getFeature(element, namespace) != null;
}
private void addStreamFeature(ExtensionElement feature) {
protected void addStreamFeature(ExtensionElement feature) {
String key = XmppStringUtils.generateKey(feature.getElementName(), feature.getNamespace());
streamFeatures.put(key, feature);
}

View File

@ -24,6 +24,7 @@ import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.jivesoftware.smack.packet.ExtensionElement;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.packet.PlainStreamElement;
import org.jivesoftware.smack.packet.TopLevelStreamElement;
@ -191,6 +192,16 @@ public class DummyConnection extends AbstractXMPPConnection {
invokePacketCollectorsAndNotifyRecvListeners(packet);
}
/**
* Enable stream feature.
*
* @param streamFeature the stream feature.
* @since 4.2
*/
public void enableStreamFeature(ExtensionElement streamFeature) {
addStreamFeature(streamFeature);
}
public static DummyConnection newConnectedDummyConnection() {
DummyConnection dummyConnection = new DummyConnection();
try {

View File

@ -40,6 +40,7 @@ import org.jivesoftware.smack.Manager;
import org.jivesoftware.smack.StanzaListener;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.SmackException.FeatureNotSupportedException;
import org.jivesoftware.smack.SmackException.NoResponseException;
import org.jivesoftware.smack.SmackException.NotConnectedException;
import org.jivesoftware.smack.SmackException.NotLoggedInException;
@ -57,6 +58,7 @@ import org.jivesoftware.smack.packet.XMPPError.Condition;
import org.jivesoftware.smack.roster.packet.RosterPacket;
import org.jivesoftware.smack.roster.packet.RosterVer;
import org.jivesoftware.smack.roster.packet.RosterPacket.Item;
import org.jivesoftware.smack.roster.packet.SubscriptionPreApproval;
import org.jivesoftware.smack.roster.rosterstore.RosterStore;
import org.jivesoftware.smack.util.Objects;
import org.jxmpp.jid.BareJid;
@ -456,6 +458,7 @@ public class Roster extends Manager {
* @param name the nickname of the user.
* @param groups the list of group names the entry will belong to, or <tt>null</tt> if the
* the roster entry won't belong to a group.
* @param approved the pre-approval state.
* @throws NoResponseException if there was no response from the server.
* @throws XMPPErrorException if an XMPP exception occurs.
* @throws NotLoggedInException If not logged in.
@ -491,6 +494,68 @@ public class Roster extends Manager {
connection.sendStanza(presencePacket);
}
/**
* Creates a new pre-approved roster entry and presence subscription. The server will
* asynchronously update the roster with the subscription status.
*
* @param user the user. (e.g. johndoe@jabber.org)
* @param name the nickname of the user.
* @param groups the list of group names the entry will belong to, or <tt>null</tt> if the
* the roster entry won't belong to a group.
* @param approved the pre-approval state.
* @throws NoResponseException if there was no response from the server.
* @throws XMPPErrorException if an XMPP exception occurs.
* @throws NotLoggedInException if not logged in.
* @throws NotConnectedException
* @throws InterruptedException
* @throws FeatureNotSupportedException if pre-approving is not supported.
* @since 4.2
*/
public void preApproveAndCreateEntry(Jid user, String name, String[] groups) throws NotLoggedInException, NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, FeatureNotSupportedException {
preApprove(user);
createEntry(user, name, groups);
}
/**
* Pre-approve user presence subscription.
*
* @param user the user. (e.g. johndoe@jabber.org)
* @throws NotLoggedInException if not logged in.
* @throws NotConnectedException
* @throws InterruptedException
* @throws FeatureNotSupportedException if pre-approving is not supported.
* @since 4.2
*/
public void preApprove(Jid user) throws NotLoggedInException, NotConnectedException, InterruptedException, FeatureNotSupportedException {
final XMPPConnection connection = connection();
if (!isSubscriptionPreApprovalSupported()) {
throw new FeatureNotSupportedException("Pre-approving");
}
Presence presencePacket = new Presence(Presence.Type.subscribed);
presencePacket.setTo(user);
connection.sendStanza(presencePacket);
}
/**
* Check for subscription pre-approval support.
*
* @return true if subscription pre-approval is supported by the server.
* @throws NotLoggedInException if not logged in.
* @since 4.2
*/
public boolean isSubscriptionPreApprovalSupported() throws NotLoggedInException {
final XMPPConnection connection = connection();
if (!connection.isAuthenticated()) {
throw new NotLoggedInException();
}
if (connection.isAnonymous()) {
throw new IllegalStateException("Anonymous users can't have a roster.");
}
return connection.hasFeature(SubscriptionPreApproval.ELEMENT, SubscriptionPreApproval.NAMESPACE);
}
/**
* Removes a roster entry from the roster. The roster entry will also be removed from the
* unfiled entries or from any roster group where it could belong and will no longer be part
@ -1329,7 +1394,7 @@ public class Roster extends Manager {
for (RosterPacket.Item item : validItems) {
RosterEntry entry = new RosterEntry(item.getUser(), item.getName(),
item.getItemType(), item.getItemStatus(), Roster.this, connection);
item.getItemType(), item.getItemStatus(), item.isApproved(), Roster.this, connection);
addUpdateEntry(addedEntries, updatedEntries, unchangedEntries, item, entry);
}
@ -1359,7 +1424,7 @@ public class Roster extends Manager {
// await possible further roster pushes.
for (RosterPacket.Item item : rosterStore.getEntries()) {
RosterEntry entry = new RosterEntry(item.getUser(), item.getName(),
item.getItemType(), item.getItemStatus(), Roster.this, connection);
item.getItemType(), item.getItemStatus(), item.isApproved(), Roster.this, connection);
addUpdateEntry(addedEntries, updatedEntries, unchangedEntries, item, entry);
}
}
@ -1429,7 +1494,7 @@ public class Roster extends Manager {
// safely retrieve this single item here.
Item item = items.iterator().next();
RosterEntry entry = new RosterEntry(item.getUser(), item.getName(),
item.getItemType(), item.getItemStatus(), Roster.this, connection);
item.getItemType(), item.getItemStatus(), item.isApproved(), Roster.this, connection);
String version = rosterPacket.getVersion();
if (item.getItemType().equals(RosterPacket.ItemType.remove)) {

View File

@ -47,6 +47,7 @@ public class RosterEntry {
private String name;
private RosterPacket.ItemType type;
private RosterPacket.ItemStatus status;
private final boolean approved;
final private Roster roster;
final private XMPPConnection connection;
@ -57,14 +58,16 @@ public class RosterEntry {
* @param name the nickname for the entry.
* @param type the subscription type.
* @param status the subscription status (related to subscriptions pending to be approbed).
* @param approved the pre-approval flag.
* @param connection a connection to the XMPP server.
*/
RosterEntry(Jid user, String name, RosterPacket.ItemType type,
RosterPacket.ItemStatus status, Roster roster, XMPPConnection connection) {
RosterPacket.ItemStatus status, boolean approved, Roster roster, XMPPConnection connection) {
this.user = user;
this.name = name;
this.type = type;
this.status = status;
this.approved = approved;
this.roster = roster;
this.connection = connection;
}
@ -124,6 +127,15 @@ public class RosterEntry {
this.status = status;
}
/**
* Returns the pre-approval state of this entry.
*
* @return the pre-approval state.
*/
public boolean isApproved() {
return approved;
}
/**
* Returns an copied list of the roster groups that this entry belongs to.
*
@ -244,6 +256,8 @@ public class RosterEntry {
}
else if (!user.equals(other.user))
return false;
if (approved != other.approved)
return false;
return true;
}
@ -251,6 +265,7 @@ public class RosterEntry {
RosterPacket.Item item = new RosterPacket.Item(entry.getUser(), entry.getName());
item.setItemType(entry.getType());
item.setItemStatus(entry.getStatus());
item.setApproved(entry.isApproved());
// Set the correct group names for the item.
for (RosterGroup group : entry.getGroups()) {
item.addGroupName(group.getName());

View File

@ -111,6 +111,7 @@ public class RosterPacket extends IQ {
private String name;
private ItemType itemType;
private ItemStatus itemStatus;
private boolean approved;
private final Set<String> groupNames;
/**
@ -190,6 +191,25 @@ public class RosterPacket extends IQ {
this.itemStatus = itemStatus;
}
/**
* Returns the roster item pre-approval state.
*
* @return the pre-approval state.
*/
public boolean isApproved() {
return approved;
}
/**
* Sets the roster item pre-approval state.
*
* @param approved the pre-approval flag.
*/
public void setApproved(boolean approved) {
this.approved = approved;
}
/**
* Returns an unmodifiable set of the group names that the roster item
* belongs to.
@ -224,6 +244,7 @@ public class RosterPacket extends IQ {
xml.optAttribute("name", name);
xml.optAttribute("subscription", itemType);
xml.optAttribute("ask", itemStatus);
xml.optBooleanAttribute("approved", approved);
xml.rightAngleBracket();
for (String groupName : groupNames) {
@ -242,6 +263,7 @@ public class RosterPacket extends IQ {
result = prime * result + ((itemType == null) ? 0 : itemType.hashCode());
result = prime * result + ((name == null) ? 0 : name.hashCode());
result = prime * result + ((user == null) ? 0 : user.hashCode());
result = prime * result + ((approved == false) ? 0 : 1);
return result;
}
@ -276,6 +298,8 @@ public class RosterPacket extends IQ {
}
else if (!user.equals(other.user))
return false;
if (approved != other.approved)
return false;
return true;
}

View File

@ -0,0 +1,49 @@
/**
*
* Copyright © 2015 Tomáš Havlas
*
* 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.roster.packet;
import org.jivesoftware.smack.packet.ExtensionElement;
import org.jivesoftware.smack.util.XmlStringBuilder;
public class SubscriptionPreApproval implements ExtensionElement {
public static final String ELEMENT = "sub";
public static final String NAMESPACE = "urn:xmpp:features:pre-approval";
public static final SubscriptionPreApproval INSTANCE = new SubscriptionPreApproval();
private SubscriptionPreApproval() {
}
@Override
public String getElementName() {
return ELEMENT;
}
@Override
public String getNamespace() {
return NAMESPACE;
}
@Override
public XmlStringBuilder toXML() {
XmlStringBuilder xml = new XmlStringBuilder(this);
xml.closeEmptyElement();
return xml;
}
}

View File

@ -21,6 +21,7 @@ import java.io.IOException;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.provider.IQProvider;
import org.jivesoftware.smack.roster.packet.RosterPacket;
import org.jivesoftware.smack.util.ParserUtils;
import org.jxmpp.jid.Jid;
import org.jxmpp.jid.impl.JidCreate;
import org.xmlpull.v1.XmlPullParser;
@ -59,6 +60,9 @@ public class RosterPacketProvider extends IQProvider<RosterPacket> {
String subscription = parser.getAttributeValue("", "subscription");
RosterPacket.ItemType type = RosterPacket.ItemType.valueOf(subscription != null ? subscription : "none");
item.setItemType(type);
// Set approval status.
boolean approved = ParserUtils.getBooleanAttribute(parser, "approved", false);
item.setApproved(approved);
break;
case "group":
// TODO item!= null

View File

@ -0,0 +1,35 @@
/**
*
* Copyright © 2015 Tomáš Havlas
*
* 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.roster.provider;
import java.io.IOException;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.provider.ExtensionElementProvider;
import org.jivesoftware.smack.roster.packet.SubscriptionPreApproval;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
public class SubscriptionPreApprovalStreamFeatureProvider extends ExtensionElementProvider<SubscriptionPreApproval> {
@Override
public SubscriptionPreApproval parse(XmlPullParser parser, int initialDepth)
throws XmlPullParserException, IOException, SmackException {
return SubscriptionPreApproval.INSTANCE;
}
}

View File

@ -188,6 +188,7 @@ public class DirectoryRosterStore implements RosterStore {
String name = null;
String type = null;
String status = null;
String approved = null;
List<String> groupNames = new ArrayList<String>();
@ -220,6 +221,10 @@ public class DirectoryRosterStore implements RosterStore {
parser.next();
status = parser.getText();
}
else if (parserName.equals("approved")) {
parser.next();
approved = parser.getText();
}
else if (parserName.equals("group")) {
parser.next();
parser.next();
@ -275,6 +280,9 @@ public class DirectoryRosterStore implements RosterStore {
item.setItemStatus(itemStatus);
}
}
if (approved != null) {
item.setApproved(Boolean.parseBoolean(approved));
}
return item;
}
@ -287,6 +295,7 @@ public class DirectoryRosterStore implements RosterStore {
xml.optElement("name", item.getName());
xml.optElement("type", item.getItemType());
xml.optElement("status", item.getItemStatus());
xml.optElement("approved", Boolean.toString(item.isApproved()));
for (String groupName : item.getGroupNames()) {
xml.openElement("group");
xml.element("groupName", groupName);

View File

@ -7,6 +7,12 @@
<className>org.jivesoftware.smack.roster.provider.RosterPacketProvider</className>
</iqProvider>
<streamFeatureProvider>
<elementName>sub</elementName>
<namespace>urn:xmpp:features:pre-approval</namespace>
<className>org.jivesoftware.smack.roster.provider.SubscriptionPreApprovalStreamFeatureProvider</className>
</streamFeatureProvider>
<streamFeatureProvider>
<elementName>ver</elementName>
<namespace>urn:xmpp:features:rosterver</namespace>

View File

@ -0,0 +1,207 @@
/**
*
* Copyright © 2015 Tomáš Havlas
*
* 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.roster;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.jivesoftware.smack.DummyConnection;
import org.jivesoftware.smack.SmackException.FeatureNotSupportedException;
import org.jivesoftware.smack.im.InitSmackIm;
import org.jivesoftware.smack.packet.IQ;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.IQ.Type;
import org.jivesoftware.smack.roster.RosterTest.TestRosterListener;
import org.jivesoftware.smack.roster.packet.RosterPacket;
import org.jivesoftware.smack.roster.packet.RosterPacket.Item;
import org.jivesoftware.smack.roster.packet.RosterPacket.ItemType;
import org.jivesoftware.smack.roster.packet.SubscriptionPreApproval;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.jxmpp.jid.Jid;
import org.jxmpp.jid.impl.JidCreate;
/**
* Tests that verifies the correct behavior of the pre-approval implementation.
*
* @see <a href="http://xmpp.org/rfcs/rfc6121.html#sub-preapproval">Pre-Approving a Subscription Request</a>
* @author Tomáš Havlas
*/
public class SubscriptionPreApprovalTest extends InitSmackIm {
private DummyConnection connection;
private Roster roster;
private TestRosterListener rosterListener;
@Before
public void setUp() throws Exception {
connection = new DummyConnection();
connection.connect();
connection.login();
rosterListener = new TestRosterListener();
roster = Roster.getInstanceFor(connection);
roster.addRosterListener(rosterListener);
connection.setPacketReplyTimeout(1000 * 60 * 5);
}
@After
public void tearDown() throws Exception {
connection.disconnect();
connection = null;
}
@Test(expected=FeatureNotSupportedException.class)
public void testPreApprovalNotSupported() throws Throwable {
final Jid contactJID = JidCreate.from("preapproval@example.com");
roster.preApprove(contactJID);
}
@Test
public void testPreApproveAndCreate() throws Throwable {
final Jid contactJID = JidCreate.from("preapproval@example.com");
final String contactName = "PreApproval";
final String[] contactGroup = {};
connection.enableStreamFeature(SubscriptionPreApproval.INSTANCE);
final PreApproveAndCreateEntryResponder serverSimulator = new PreApproveAndCreateEntryResponder() {
@Override
void verifyRosterUpdateRequest(final RosterPacket updateRequest) {
final Item item = updateRequest.getRosterItems().iterator().next();
assertSame("The provided JID doesn't match the requested!",
contactJID,
item.getUser());
assertSame("The provided name doesn't match the requested!",
contactName,
item.getName());
assertSame("The provided group number doesn't match the requested!",
0,
item.getGroupNames().size());
}
@Override
void verifyPreApprovalRequest(Presence preApproval) {
assertSame("The provided name doesn't match the requested!",
contactJID,
preApproval.getTo());
assertSame("The provided presence type is incorrect!",
Presence.Type.subscribed,
preApproval.getType());
}
};
serverSimulator.start();
roster.preApproveAndCreateEntry(contactJID, contactName, contactGroup);
serverSimulator.join();
// Check if an error occurred within the simulator
final Throwable exception = serverSimulator.getException();
if (exception != null) {
throw exception;
}
rosterListener.waitUntilInvocationOrTimeout();
// Verify the roster entry of the new contact
final RosterEntry addedEntry = roster.getEntry(contactJID);
assertNotNull("The new contact wasn't added to the roster!", addedEntry);
assertTrue("The roster listener wasn't invoked for the new contact!",
rosterListener.getAddedAddresses().contains(contactJID));
assertSame("Setup wrong name for the new contact!",
contactName,
addedEntry.getName());
assertSame("Setup wrong default subscription status!",
ItemType.none,
addedEntry.getType());
assertSame("The new contact should be member of exactly one group!",
0,
addedEntry.getGroups().size());
}
/**
* This class can be used to simulate the server response for
* a pre approve request request.
*/
private abstract class PreApproveAndCreateEntryResponder extends Thread {
private Throwable exception = null;
/**
* Overwrite this method to check if the received roster update request is valid.
*
* @param updateRequest the request which would be sent to the server.
*/
abstract void verifyRosterUpdateRequest(final RosterPacket updateRequest);
/**
* Overwrite this method to check if recieved pre-approval request is valid
*
* @param preApproval the request which would be sent to server.
*/
abstract void verifyPreApprovalRequest(final Presence preApproval);
public void run() {
try {
while (true) {
final Stanza packet = connection.getSentPacket();
if (packet instanceof RosterPacket && ((IQ) packet).getType() == Type.set) {
final RosterPacket rosterRequest = (RosterPacket) packet;
// Prepare and process the roster push
final RosterPacket rosterPush = new RosterPacket();
final Item item = rosterRequest.getRosterItems().iterator().next();
if (item.getItemType() != ItemType.remove) {
item.setItemType(ItemType.none);
}
rosterPush.setType(Type.set);
rosterPush.setTo(connection.getUser());
rosterPush.addRosterItem(item);
connection.processPacket(rosterPush);
// Create and process the IQ response
final IQ response = IQ.createResultIQ(rosterRequest);
connection.processPacket(response);
// Verify the roster update request
assertSame("A roster set MUST contain one and only one <item/> element.",
1,
rosterRequest.getRosterItemCount());
verifyRosterUpdateRequest(rosterRequest);
break;
}
else if (packet instanceof Presence && ((Presence) packet).getType() == Presence.Type.subscribed) {
final Presence approval = (Presence) packet;
verifyPreApprovalRequest(approval);
}
}
}
catch (Throwable e) {
exception = e;
fail(e.getMessage());
}
}
/**
* Returns the exception or error if something went wrong.
*
* @return the Throwable exception or error that occurred.
*/
public Throwable getException() {
return exception;
}
}
}

View File

@ -107,6 +107,8 @@ public class DirectoryRosterStoreTest {
item1.getItemType(), storedItem.getItemType());
assertEquals("ItemStatus of added entry",
item1.getItemStatus(), storedItem.getItemStatus());
assertEquals("Approved of added entry",
item1.isApproved(), storedItem.isApproved());
final String version2 = "2";
@ -115,6 +117,7 @@ public class DirectoryRosterStoreTest {
item2.addGroupName("examples");
item2.setItemStatus(ItemStatus.subscribe);
item2.setItemType(ItemType.none);
item2.setApproved(true);
store.addEntry(item2,version2);
assertEquals("Updating entry sets version correctly", version2, store.getRosterVersion());
storedItem = store.getEntry(userName);
@ -128,6 +131,8 @@ public class DirectoryRosterStoreTest {
item2.getItemType(), storedItem.getItemType());
assertEquals("ItemStatus of added entry",
item2.getItemStatus(), storedItem.getItemStatus());
assertEquals("Approved of added entry",
item2.isApproved(), storedItem.isApproved());
List<Item> entries = store.getEntries();
assertEquals("Number of entries", 1, entries.size());
@ -143,6 +148,7 @@ public class DirectoryRosterStoreTest {
item4.addGroupName("Bar Friends");
item4.setItemStatus(ItemStatus.subscribe);
item4.setItemType(ItemType.both);
item4.setApproved(true);
ArrayList<Item> items34 = new ArrayList<RosterPacket.Item>();
items34.add(item3);
@ -162,6 +168,8 @@ public class DirectoryRosterStoreTest {
item3.getItemType(), storedItem.getItemType());
assertEquals("ItemStatus of added entry",
item3.getItemStatus(), storedItem.getItemStatus());
assertEquals("Approved of added entry",
item3.isApproved(), storedItem.isApproved());
storedItem = store.getEntry(JidTestUtil.BARE_JID_2);
@ -175,6 +183,8 @@ public class DirectoryRosterStoreTest {
item4.getItemType(), storedItem.getItemType());
assertEquals("ItemStatus of added entry",
item4.getItemStatus(), storedItem.getItemStatus());
assertEquals("Approved of added entry",
item4.isApproved(), storedItem.isApproved());
entries = store.getEntries();
assertEquals("Number of entries", 2, entries.size());