/* * Jitsi, the OpenSource Java VoIP and Instant Messaging client. * * Copyright @ 2015 Atlassian Pty Ltd * * 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 net.java.sip.communicator.impl.protocol.irc; import java.io.*; import java.util.*; import java.util.concurrent.atomic.*; import net.java.sip.communicator.impl.protocol.irc.collection.*; import net.java.sip.communicator.util.*; import com.ircclouds.irc.api.*; import com.ircclouds.irc.api.domain.messages.*; import com.ircclouds.irc.api.state.*; /** * Manager for presence status of IRC connection. * * There is support for online presence by polling (periodically querying IRC * server with ISON requests) for each of the members in the contact list or, if * supported by the IRC server, for the MONITOR command to subscribe to presence * notifications for the specified nick. * * TODO Support for 'a' (Away) user mode. (Check this again, since I also see * 'a' used for other purposes. This may be one of those ambiguous letters that * every server interprets differently.) * * TODO Support away-notify extension (CAP) and handle AWAY messages * appropriately. * * @author Danny van Heumen */ public class PresenceManager { /** * Logger. */ private static final Logger LOGGER = Logger .getLogger(PresenceManager.class); /** * IRC client library instance. * * Instance must be thread-safe! */ private final IRCApi irc; /** * IRC client connection state. */ private final IIRCState connectionState; /** * Instance of OperationSetPersistentPresence for updates. */ private final OperationSetPersistentPresenceIrcImpl operationSet; /** * Presence watcher. */ private final PresenceWatcher watcher; /** * Maximum away message length according to server ISUPPORT instructions. * *

This value is not guaranteed, so it may be null.

*/ private final Integer isupportAwayLen; /** * Maximum size of MONITOR list allowed by server. * *

* This value is not guaranteed, so it may be null. If it is * null this means that MONITOR is not supported by this server. *

*/ private final Integer isupportMonitor; /** * Maximum size of WATCH list allowed by server. * *

* This value is not guaranteed, so it may be null. If it is * null this means that WATCH is not supported by this server. *

*/ private final Integer isupportWatch; /** * Server identity. */ private final AtomicReference serverIdentity = new AtomicReference(null); /** * Current presence status. */ private volatile boolean away = false; /** * Active away message. */ private volatile String currentMessage = ""; /** * Proposed away message. */ private volatile String submittedMessage = "Away"; /** * Constructor. * * @param irc thread-safe irc client library instance * @param connectionState irc client connection state instance * @param operationSet OperationSetPersistentPresence irc implementation for * handling presence changes. * @param config Client configuration * @param persistentNickWatchList persistent nick watch list to use (The * sortedset implementation must be synchronized!) */ public PresenceManager(final IRCApi irc, final IIRCState connectionState, final OperationSetPersistentPresenceIrcImpl operationSet, final ClientConfig config, final SortedSet persistentNickWatchList) { if (connectionState == null) { throw new IllegalArgumentException( "connectionState cannot be null"); } this.connectionState = connectionState; if (operationSet == null) { throw new IllegalArgumentException("operationSet cannot be null"); } this.operationSet = operationSet; if (irc == null) { throw new IllegalArgumentException("irc cannot be null"); } this.irc = irc; final SortedSet nickWatchList; if (persistentNickWatchList == null) { // watch list will be non-persistent, since we create an instance at // initialization time nickWatchList = Collections.synchronizedSortedSet(new TreeSet()); } else { nickWatchList = persistentNickWatchList; } this.irc.addListener(new LocalUserPresenceListener()); // TODO move parse methods to ISupport enum type this.isupportAwayLen = parseISupportAwayLen(this.connectionState); this.isupportMonitor = parseISupportMonitor(this.connectionState); this.isupportWatch = parseISupportWatch(this.connectionState); final boolean enablePresencePolling = config.isContactPresenceTaskEnabled(); if (this.isupportMonitor != null) { // Share a list of monitored nicks between the // MonitorPresenceWatcher and the BasicPollerPresenceWatcher. // Now it is possible for the basic poller to determine whether // or not to poll for a certain nick, such that we do not poll // nicks that are already monitored. final SortedSet monitoredNicks = Collections.synchronizedSortedSet(new TreeSet()); this.watcher = new MonitorPresenceWatcher(this.irc, this.connectionState, nickWatchList, monitoredNicks, this.operationSet, this.isupportMonitor); if (enablePresencePolling) { // Enable basic poller as fall back mechanism. // Create a dynamic set that automatically computes the // difference between the full nick list and the list of nicks // that are subscribed to MONITOR. The difference will be the // result that is used by the basic poller. final Set unmonitoredNicks = new DynamicDifferenceSet(nickWatchList, monitoredNicks); new BasicPollerPresenceWatcher(this.irc, this.connectionState, this.operationSet, unmonitoredNicks, this.serverIdentity); } } else if (this.isupportWatch != null) { // Share a list of monitored nicks between the // WatchPresenceWatcher and the BasicPollerPresenceWatcher. // Now it is possible for the basic poller to determine whether // or not to poll for a certain nick, such that we do not poll // nicks that are already monitored. final SortedSet monitoredNicks = Collections.synchronizedSortedSet(new TreeSet()); this.watcher = new WatchPresenceWatcher(this.irc, this.connectionState, nickWatchList, monitoredNicks, this.operationSet, this.isupportWatch); if (enablePresencePolling) { // Enable basic poller as fall back mechanism. // Create a dynamic set that automatically computes the // difference between the full nick list and the list of nicks // that are subscribed to WATCH. The difference will be the // result that is used by the basic poller. final Set unmonitoredNicks = new DynamicDifferenceSet(nickWatchList, monitoredNicks); new BasicPollerPresenceWatcher(this.irc, this.connectionState, this.operationSet, unmonitoredNicks, this.serverIdentity); } } else if (enablePresencePolling) { // Enable basic poller as the only presence mechanism. this.watcher = new BasicPollerPresenceWatcher(this.irc, this.connectionState, this.operationSet, nickWatchList, this.serverIdentity); } else { this.watcher = null; } } /** * Parse the ISUPPORT parameter for server's away message length. * * @param state the connection state * @return returns instance with max away message length or null if * not specified. */ private Integer parseISupportAwayLen(final IIRCState state) { final String value = state.getServerOptions().getKey(ISupport.AWAYLEN.name()); if (value == null) { LOGGER.trace("No ISUPPORT parameter " + ISupport.AWAYLEN.name() + " available."); return null; } if (LOGGER.isDebugEnabled()) { LOGGER.debug("Setting ISUPPORT parameter " + ISupport.AWAYLEN.name() + " to " + value); } try { return new Integer(value); } catch (RuntimeException e) { LOGGER.warn("Failed to parse AWAYLEN value.", e); return null; } } /** * Parse the ISUPPORT parameter for MONITOR command support and list size. * * @param state the connection state * @return Returns instance with maximum number of entries in MONITOR list. * Additionally, having this MONITOR property available, indicates * that MONITOR is supported by the server. */ private Integer parseISupportMonitor(final IIRCState state) { final String value = state.getServerOptions().getKey(ISupport.MONITOR.name()); if (value == null) { LOGGER.trace("No ISUPPORT parameter " + ISupport.MONITOR.name() + " available."); return null; } if (LOGGER.isDebugEnabled()) { LOGGER.debug("Setting ISUPPORT parameter " + ISupport.MONITOR.name() + " to " + value); } try { return new Integer(value); } catch (RuntimeException e) { LOGGER.warn("Failed to parse MONITOR value.", e); return null; } } /** * Parse the ISUPPORT parameter for WATCH command support and list size. * * @param state the connection state * @return Returns instance with maximum number of entries in WATCH list. * Additionally, having this WATCH property available, indicates * that WATCH is supported by the server. */ private Integer parseISupportWatch(final IIRCState state) { final String value = state.getServerOptions().getKey(ISupport.WATCH.name()); if (value == null) { LOGGER.trace("No ISUPPORT parameter " + ISupport.WATCH.name() + " available."); return null; } if (LOGGER.isDebugEnabled()) { LOGGER.debug("Setting ISUPPORT parameter " + ISupport.WATCH.name() + " to " + value); } try { return new Integer(value); } catch (RuntimeException e) { LOGGER.warn("Failed to parse WATCH value.", e); return null; } } /** * Check current Away state. * * @return returns true if away or false if not away */ public boolean isAway() { return this.away; } /** * Get away message. * * @return returns currently active away message or "" if currently not * away. */ public String getMessage() { return this.currentMessage; } /** * Set away status and message. Disable away status by providing * null message. * * @param isAway true to enable away mode + message, or * false to disable * @param awayMessage away message, the message is only available when the * local user is set to away. If null is provided, don't * set a new away message. */ public void away(final boolean isAway, final String awayMessage) { if (awayMessage != null) { this.submittedMessage = verifyMessage(awayMessage); } if (isAway && (!this.away || awayMessage != null)) { // In case we aren't AWAY yet, or if there is a message to set. this.irc.rawMessage("AWAY :" + this.submittedMessage); } else if (isAway != this.away) { this.irc.rawMessage("AWAY"); } } /** * Set new prepared away message for later moment when IRC connection is set * to away. * * @param message the away message to prepare * @return returns message after verification */ private String verifyMessage(final String message) { if (message == null || message.isEmpty()) { throw new IllegalArgumentException( "away message must be non-null and non-empty"); } if (this.isupportAwayLen != null && message.length() > this.isupportAwayLen.intValue()) { throw new IllegalArgumentException( "the away message must not be longer than " + this.isupportAwayLen.intValue() + " characters according to server's parameters."); } return message; } /** * Query presence of provided nick. * * @param nick the nick * @return returns presence status * @throws InterruptedException interrupted exception in case waiting for * WHOIS reply is interrupted * @throws IOException an exception occurred during the querying process */ public IrcStatusEnum query(final String nick) throws InterruptedException, IOException { final Result result = new Result( IrcStatusEnum.OFFLINE); synchronized (result) { this.irc.addListener(new WhoisReplyListener(nick, result)); this.irc.rawMessage("WHOIS " + IdentityManager.checkNick(nick, null)); while (!result.isDone()) { LOGGER.debug("Waiting for presence status based on WHOIS " + "reply ..."); result.wait(); } } final Exception exception = result.getException(); if (exception == null) { return result.getValue(); } else { throw new IOException( "An exception occured while querying whois info.", result.getException()); } } /** * Add new nick to watch list. * * @param nick nick to add to watch list */ public void addNickWatch(final String nick) { if (this.watcher != null) { this.watcher.add(nick); } } /** * Remove nick from watch list. * * @param nick nick to remove from watch list */ public void removeNickWatch(final String nick) { if (this.watcher != null) { this.watcher.remove(nick); } } /** * Presence listener implementation for keeping track of presence changes in * the IRC connection. * * @author Danny van Heumen */ private final class LocalUserPresenceListener extends AbstractIrcMessageListener { /** * Reply for acknowledging transition to available (not away any * longer). */ private static final int IRC_RPL_UNAWAY = 305; /** * Reply for acknowledging transition to away. */ private static final int IRC_RPL_NOWAWAY = 306; /** * Constructor. */ public LocalUserPresenceListener() { super(PresenceManager.this.irc, PresenceManager.this.connectionState); } /** * Handle events for presence-related server replies. */ @Override public void onServerNumericMessage(final ServerNumericMessage msg) { if (PresenceManager.this.serverIdentity.get() == null) { PresenceManager.this.serverIdentity.set(msg.getSource() .getHostname()); } Integer msgCode = msg.getNumericCode(); if (msgCode == null) { return; } int code = msgCode.intValue(); switch (code) { case IRC_RPL_UNAWAY: PresenceManager.this.currentMessage = ""; PresenceManager.this.away = false; operationSet.updatePresenceStatus(IrcStatusEnum.AWAY, IrcStatusEnum.ONLINE); LOGGER.debug("Away status disabled."); break; case IRC_RPL_NOWAWAY: PresenceManager.this.currentMessage = PresenceManager.this.submittedMessage; PresenceManager.this.away = true; operationSet.updatePresenceStatus(IrcStatusEnum.ONLINE, IrcStatusEnum.AWAY); LOGGER.debug("Away status enabled with message \"" + PresenceManager.this.currentMessage + "\""); break; default: break; } } } /** * Listener for WHOIS replies, such that we can receive information of the * user that we are querying. * * @author Danny van Heumen */ private final class WhoisReplyListener extends AbstractIrcMessageListener { /** * Reply for away message. */ private static final int IRC_RPL_AWAY = 301; /** * Reply for WHOIS query with user info. */ private static final int IRC_RPL_WHOISUSER = 311; /** * Reply for signaling end of WHOIS query. */ private static final int IRC_RPL_ENDOFWHOIS = 318; /** * The nick that is being queried. */ private final String nick; /** * The result instance that will be updated after having received the * RPL_ENDOFWHOIS reply. */ private final Result result; /** * Intermediate presence status. Updated upon receiving new WHOIS * information. */ private IrcStatusEnum presence; /** * Constructor. * * @param nick the nick * @param result the result */ private WhoisReplyListener(final String nick, final Result result) { super(PresenceManager.this.irc, PresenceManager.this.connectionState); if (nick == null) { throw new IllegalArgumentException("Invalid nick specified."); } this.nick = nick; if (result == null) { throw new IllegalArgumentException("Invalid result."); } this.result = result; this.presence = IrcStatusEnum.OFFLINE; } /** * Handle the numeric messages that the WHOIS answer consists of. * * @param msg the numeric message */ @Override public void onServerNumericMessage(final ServerNumericMessage msg) { if (!this.nick.equals(msg.getTarget())) { return; } switch (msg.getNumericCode()) { case IRC_RPL_WHOISUSER: if (this.presence != IrcStatusEnum.AWAY) { // only update presence if not set to away, since away // status is more specific than the more general information // of being online this.presence = IrcStatusEnum.ONLINE; } break; case IRC_RPL_AWAY: this.presence = IrcStatusEnum.AWAY; break; case IRC_RPL_ENDOFWHOIS: this.irc.deleteListener(this); synchronized (this.result) { this.result.setDone(this.presence); this.result.notifyAll(); } break; default: break; } } /** * Upon connection quitting, set exception and return result. */ @Override public void onUserQuit(QuitMessage msg) { super.onUserQuit(msg); if (localUser(msg.getSource().getNick())) { synchronized (this.result) { this.result.setDone(new IllegalStateException( "Local user quit.")); this.result.notifyAll(); } } } /** * Upon receiving an error, set exception and return result. */ @Override public void onError(ErrorMessage msg) { super.onError(msg); synchronized (this.result) { this.result.setDone(new IllegalStateException( "An error occurred: " + msg.getText())); this.result.notifyAll(); } } /** * In case a client-side fatal error occurs, remove the listener. * * @param msg the error message */ @Override public void onClientError(ClientErrorMessage msg) { super.onClientError(msg); synchronized (this.result) { this.result.setDone(new IllegalStateException( "An error occurred: " + msg.asRaw())); this.result.notifyAll(); } } } }