/* * Copyright 2006-2010 Daniel Henninger. All rights reserved. * * This software is published under the terms of the GNU Public License (GPL), * a copy of which is included in this distribution. */ package net.sf.kraken.protocols.oscar; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import net.kano.joscar.ByteBlock; import net.kano.joscar.snaccmd.ExtraInfoData; import net.kano.joscar.snaccmd.ssi.BuddyAuthRequest; import net.kano.joscar.snaccmd.ssi.CreateItemsCmd; import net.kano.joscar.snaccmd.ssi.DeleteItemsCmd; import net.kano.joscar.snaccmd.ssi.ModifyItemsCmd; import net.kano.joscar.snaccmd.ssi.PostModCmd; import net.kano.joscar.snaccmd.ssi.PreModCmd; import net.kano.joscar.snaccmd.ssi.SsiCommand; import net.kano.joscar.snaccmd.ssi.SsiItem; import net.kano.joscar.ssiitem.BuddyItem; import net.kano.joscar.ssiitem.GroupItem; import net.kano.joscar.ssiitem.IconItem; import net.kano.joscar.ssiitem.VisibilityItem; import net.sf.kraken.roster.TransportBuddy; import net.sf.kraken.roster.TransportBuddyManager; import net.sf.kraken.type.TransportType; import org.apache.log4j.Logger; import org.jivesoftware.util.NotFoundException; import org.xmpp.packet.JID; /** * A representation of a Server Stored Information hierarchy. * * Implementation guide: this class encapsulates all SSI-specific attributes. In * particular, no other class outside this one in Kraken's code should have to * worry about what ID is valid in what group context. All of that logic must be * kept within this class! * * @see <a * href="http://code.google.com/p/joscar/wiki/SsiItems">JOscar SSI Items documentation</a> */ public class SSIHierarchy { private static final Logger Log = Logger.getLogger(SSIHierarchy.class); /** * Default group name for AIM. This group name should be used only if no * group name was provided by the end user, and no existing groups are * available. */ private static final String DEFAULT_AIM_GROUP = "Buddies"; /** * Default group name for ICQ. This group name should be used only if no * group name was provided by the end user, and no existing groups are * available. */ private static final String DEFAULT_ICQ_GROUP = "General"; /** * A reference to the session that instantiated this {@link SSIHierarchy} * instance. */ private final OSCARSession parent; /** * A list of known groups, mapped by their unique group IDs. */ private final Map<Integer, GroupItem> groups = new ConcurrentHashMap<Integer, GroupItem>(); /** * The highest buddy ID in a particular group (mapped by the group ID). */ private final Map<Integer, Integer> highestBuddyIdPerGroup = new ConcurrentHashMap<Integer, Integer>(); /** * The (unique) visibility settings item */ private VisibilityItem visibility; /** * The (unique) icon item */ private IconItem icon; /** * Avatar byte data that is likely to be requested by the OSCAR network. */ private byte[] pendingAvatar; /** * Instantiates a new {@link SSIHierarchy} object, which is linked to a * {@link OSCARSession}. * * @param parent * The session of this {@link SSIHierarchy} instance. */ public SSIHierarchy(OSCARSession parent) { if (parent == null) { throw new IllegalArgumentException( "Argument 'parent' cannot be null."); } this.parent = parent; highestBuddyIdPerGroup.put(0, 0); // Main group highest id } /** * Sends data to the network. * * @param command * the data to send. */ private void request(SsiCommand command) { parent.request(command); } /** * Updates the avatar of the entity owning this instance on the network. */ public void setIcon(final String type, final byte[] data) { this.pendingAvatar = data; try { final MessageDigest digest = MessageDigest.getInstance("MD5"); digest.update(data); final ExtraInfoData eid = new ExtraInfoData( ExtraInfoData.FLAG_HASH_PRESENT, ByteBlock.wrap(digest .digest())); final SsiCommand request; final IconItem newIconItem; if (icon != null) { newIconItem = new IconItem(icon); newIconItem.setIconInfo(eid); request = new ModifyItemsCmd(newIconItem.toSsiItem()); } else { newIconItem = new IconItem(IconItem.NAME_DEFAULT, this .getNextBuddyId(SsiItem.GROUP_ROOT), eid); request = new CreateItemsCmd(newIconItem.toSsiItem()); } request(new PreModCmd()); request(request); request(new PostModCmd()); this.icon = newIconItem; } catch (NoSuchAlgorithmException e) { Log.error("No algorithm found for MD5 checksum??"); } } /** * Adds a visibility settings flag, if that flag had not been set * previously. * * @param flag * the that will be added to the existing settings. * @see VisibilityItem */ public void setVisibilityFlag(long flag) { if (visibility != null) { if ((visibility.getVisFlags() & flag) == 0) { visibility.setVisFlags((visibility.getVisFlags() | flag)); request(new ModifyItemsCmd(visibility.toSsiItem())); } } else { final VisibilityItem newItem = new VisibilityItem( getNextBuddyId(SsiItem.GROUP_ROOT), SsiItem.GROUP_ROOT); newItem.setVisFlags(flag); this.visibility = newItem; request(new CreateItemsCmd(newItem.toSsiItem())); } } /** * Update highest buddy id in a group if new id is indeed higher. * * @param buddyItem * Buddy item to compare. */ private void updateHighestId(BuddyItem buddyItem) { if (!highestBuddyIdPerGroup.containsKey(buddyItem.getGroupId())) { highestBuddyIdPerGroup.put(buddyItem.getGroupId(), 0); } if (buddyItem.getId() > highestBuddyIdPerGroup.get(buddyItem .getGroupId())) { highestBuddyIdPerGroup.put(buddyItem.getGroupId(), buddyItem .getId()); } } /** * Returns the name of a default group to which a new user should be added, * if a new contact item was created without a group. * * This method will first check for existing groups. If groups exist. The * first group (the group with the lowest ID) will be returned as the * default group. If no groups exist, a hardcoded default group name will be * used. * * @return a group name (never <tt>null</tt>). */ private String getDefaultGroup() { if (!groups.isEmpty()) { // if existing groups are available, use the first one. final Integer firstKey = Collections.min(groups.keySet()); final GroupItem firstKnownGroup = groups.get(firstKey); Log.debug("Returning first group as default group name: " + firstKnownGroup.getGroupName()); return firstKnownGroup.getGroupName(); } else { // in the (unlikely?) situation that we do not know about existing // groups, use a hardcoded default one. Log.debug("Returning hard coded value as default group name " + "(no existing groups are available)."); if (TransportType.icq.equals(parent.getTransport().getType())) { return DEFAULT_ICQ_GROUP; } else { return DEFAULT_AIM_GROUP; } } } /** * Synchronizes the basic characteristics of one contact, including: * <ul> * <li>the list of groups a contact is a member of</li> * <li>nicknames</li> * </ul> * * As an OSCAR contact must be in at least one group, a default group is * used if the provided group list is empty or <tt>null</tt>. * * @param contact * Screen name/UIN of the contact. * @param nickname * Nickname of the contact (should not be <tt>null</tt>) * @param grouplist * List of groups the contact should be a member of. * @see SSIHierarchy#getDefaultGroup() */ public void syncContactGroupsAndNickname(String contact, String nickname, List<String> grouplist) { if (grouplist == null) { grouplist = new ArrayList<String>(); } if (grouplist.isEmpty()) { Log.debug("No groups provided for the sync of contact " + contact + ". Using default group."); grouplist.add(getDefaultGroup()); } Log.debug("Syncing contact = "+contact+", nickname = "+nickname+", grouplist = "+grouplist); OSCARBuddy oscarBuddy = null; try { final JID jid = parent.getTransport().convertIDToJID(contact); oscarBuddy = parent.getBuddyManager().getBuddy(jid); Log.debug("Found related oscarbuddy: " + oscarBuddy); } catch (NotFoundException e) { Log.debug("Didn't find related oscarbuddy. One will be created."); } //TODO: Should we do a premodcmd here and postmodcmd at the end and not have separate ones? // Stored 'removed' list of buddy items for later use final List<BuddyItem> freeBuddyItems = new ArrayList<BuddyItem>(); // First, lets clean up any groups this contact should no longer be a member of. // We'll keep the buddy items around for potential modification instead of deletion. if (oscarBuddy != null) { for (BuddyItem buddy : oscarBuddy.getBuddyItems()) { // if (buddy.getScreenname().equalsIgnoreCase(contact)) { if (!groups.containsKey(buddy.getGroupId())) { // Well this is odd, a group we don't know about? Nuke it. Log.debug("Removing "+buddy+" because of unknown group"); freeBuddyItems.add(buddy); // request(new DeleteItemsCmd(buddy.toSsiItem())); // oscarBuddy.removeBuddyItem(buddy.getGroupId(), true); } else if (!grouplist.contains(groups.get(buddy.getGroupId()).getGroupName())) { Log.debug("Removing "+buddy+" because not in list of groups"); freeBuddyItems.add(buddy); // request(new DeleteItemsCmd(buddy.toSsiItem())); // oscarBuddy.removeBuddyItem(buddy.getGroupId(), true); } else { // nothing to delete? lets update Aliases then. if (buddy.getAlias() == null || !buddy.getAlias().equals(nickname)) { Log.debug("Updating alias for "+buddy); buddy.setAlias(nickname); request(new PreModCmd()); request(new ModifyItemsCmd(buddy.toSsiItem())); request(new PostModCmd()); updateHighestId(buddy); oscarBuddy.tieBuddyItem(buddy, true); } } // } } } // Now, lets take the known good list of groups and add whatever is missing on the server. for (String group : grouplist) { Integer groupId = getGroupIdOrCreateNew(group); if (isMemberOfGroup(groupId, contact)) { // Already a member, moving on continue; } Integer newBuddyId = 1; if (highestBuddyIdPerGroup.containsKey(groupId)) { newBuddyId = getNextBuddyId(groupId); } if (freeBuddyItems.size() > 0) { // Moving a freed buddy item // TODO: This isn't working.. why? Returns RESULT_ID_TAKEN // BuddyItem buddy = freeBuddyItems.remove(0); // if (oscarBuddy != null) { // oscarBuddy.removeBuddyItem(buddy.getGroupId(), false); // } // buddy.setGroupid(groupId); // buddy.setId(newBuddyId); // buddy.setAlias(nickname); // request(new ModifyItemsCmd(buddy.toSsiItem())); // if (oscarBuddy == null) { // oscarBuddy = new OSCARBuddy(getBuddyManager(), buddy); // // TODO: translate this // request(new BuddyAuthRequest(contact, "Automated add request on behalf of user.")); // } // else { // oscarBuddy.tieBuddyItem(buddy, false); // } request(new PreModCmd()); BuddyItem buddy = freeBuddyItems.remove(0); BuddyItem newBuddy = new BuddyItem(buddy); newBuddy.setGroupid(groupId); newBuddy.setId(newBuddyId); newBuddy.setAlias(nickname); request(new DeleteItemsCmd(buddy.toSsiItem())); if (oscarBuddy != null) { oscarBuddy.removeBuddyItem(buddy.getGroupId(), false); } request(new CreateItemsCmd(newBuddy.toSsiItem())); if (oscarBuddy == null) { oscarBuddy = new OSCARBuddy(parent.getBuddyManager(), newBuddy); // TODO: translate this request(new BuddyAuthRequest(contact, "Automated add request on behalf of user.")); } else { oscarBuddy.tieBuddyItem(newBuddy, false); } request(new PostModCmd()); } else { // Creating a new buddy item final BuddyItem newBuddy = new BuddyItem(contact, groupId, newBuddyId); newBuddy.setAlias(nickname); updateHighestId(newBuddy); // TODO: Should we be doing this for AIM too? if (parent.getTransport().getType().equals(TransportType.icq)) { newBuddy.setAwaitingAuth(true); } request(new PreModCmd()); request(new CreateItemsCmd(newBuddy.toSsiItem())); request(new PostModCmd()); if (oscarBuddy == null) { oscarBuddy = new OSCARBuddy(parent.getBuddyManager(), newBuddy); // TODO: translate this request(new BuddyAuthRequest(contact, "Automated add request on behalf of user.")); } else { oscarBuddy.tieBuddyItem(newBuddy, true); } } } // Now, lets remove any leftover buddy items that we're no longer using. for (BuddyItem buddy : freeBuddyItems) { request(new DeleteItemsCmd(buddy.toSsiItem())); if (oscarBuddy != null) { oscarBuddy.removeBuddyItem(buddy.getGroupId(), false); } } // Lastly, lets store the final buddy item after we've modified it, making sure to update groups first. if (oscarBuddy != null) { // oscarBuddy.populateGroupList(); parent.getBuddyManager().storeBuddy(oscarBuddy); } } /** * Returns the name of a group, based on its ID. * * @param groupID * the ID of the group. * @return the name of the group */ public String getGroupName(int groupID) { if (!groups.containsKey(groupID)) { return null; } return groups.get(groupID).getGroupName(); } /** * Finds the id number of a group specified or creates a new one and returns * that id. * * @param groupName * Name of the group we are looking for. * @return Id number of the group. */ public int getGroupIdOrCreateNew(String groupName) { for (final GroupItem g : groups.values()) { if (groupName.equalsIgnoreCase(g.getGroupName())) { return g.getId(); } } // Group doesn't exist, lets create a new one. final int newGroupId = getNextBuddyId(0); final GroupItem newGroup = new GroupItem(groupName, newGroupId); request(new CreateItemsCmd(newGroup.toSsiItem())); gotGroup(newGroup); return newGroupId; } /** * Determines if a contact is a member of a group. * * @param groupId * ID of group to check * @param member * Screen name of member * @return True or false if member is in group with id groupId */ public boolean isMemberOfGroup(int groupId, String member) { for (TransportBuddy buddy : parent.getBuddyManager().getBuddies()) { if (buddy.getName().equalsIgnoreCase(member) && ((OSCARBuddy) buddy).getBuddyItem(groupId) != null) { return true; } } return false; } /** * Retrieves the next highest buddy ID for a group and stores the new * highest id. * * @param groupId * ID of group to get highest of * @return ID number of buddy ID you should use. */ public int getNextBuddyId(int groupId) { final int id = highestBuddyIdPerGroup.get(groupId) + 1; highestBuddyIdPerGroup.put(groupId, id); return id; } /** * Deletes a buddy from the contact list. * * @param buddy * the buddy to be deleted. */ public void delete(BuddyItem buddy) { request(new DeleteItemsCmd(buddy.toSsiItem())); } /** * Avatar byte data that is likely to be requested by the OSCAR network. * * @return Avatar byte data */ public byte[] getPendingAvatarData() { return pendingAvatar; } /** * Clears the pending avatar data, freeing up (a lot of) memory. */ public void clearPendingAvatar() { pendingAvatar = null; } // Below this point: Event handlers used to fill instances of this class, // based on incoming data from the AIM network. /** * We've been told about a group that exists on the buddy list. * * @param group * The group we've been told about. */ void gotGroup(GroupItem group) { Log.debug("Found group item: " + group.toString() + " at id " + group.getId()); groups.put(group.getId(), group); if (!highestBuddyIdPerGroup.containsKey(0)) { highestBuddyIdPerGroup.put(0, 0); } if (group.getId() > highestBuddyIdPerGroup.get(0)) { highestBuddyIdPerGroup.put(0, group.getId()); } } /** * We've been told about an icon that exists on the buddy list. * * @param iconItem * The icon info we've been told about. */ void gotIconItem(IconItem iconItem) { this.icon = iconItem; } /** * We've been told about a visibility item that exists on the buddy list. * * @param visibilityItem * The visibility info we've been told about. */ void gotVisibilityItem(VisibilityItem visibilityItem) { this.visibility = visibilityItem; } /** * We've been told about a buddy that exists on the buddy list. * * @param buddyItem * the buddy we've been told about. */ public void gotBuddy(BuddyItem buddyItem) { updateHighestId(buddyItem); final TransportBuddyManager<OSCARBuddy> buddyManager = parent.getBuddyManager(); try { final JID jid = parent.getTransport().convertIDToJID( buddyItem.getScreenname()); final OSCARBuddy oscarBuddy = buddyManager .getBuddy(jid); oscarBuddy.tieBuddyItem(buddyItem, false); } catch (NotFoundException ee) { final OSCARBuddy oscarBuddy = new OSCARBuddy(buddyManager, buddyItem); buddyManager.storeBuddy(oscarBuddy); } } // Up to this point: Event handlers used to fill instances of this class, // based on incoming data from the AIM network. }