001/**
002 *
003 * Copyright 2003-2007 Jive Software, 2018 Paul Schaub.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package org.jivesoftware.smackx.chatstates;
019
020import java.util.ArrayList;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025import java.util.WeakHashMap;
026import java.util.logging.Level;
027import java.util.logging.Logger;
028
029import org.jivesoftware.smack.AsyncButOrdered;
030import org.jivesoftware.smack.Manager;
031import org.jivesoftware.smack.SmackException.NotConnectedException;
032import org.jivesoftware.smack.StanzaListener;
033import org.jivesoftware.smack.XMPPConnection;
034import org.jivesoftware.smack.chat2.Chat;
035import org.jivesoftware.smack.chat2.ChatManager;
036import org.jivesoftware.smack.chat2.OutgoingChatMessageListener;
037import org.jivesoftware.smack.filter.AndFilter;
038import org.jivesoftware.smack.filter.FromTypeFilter;
039import org.jivesoftware.smack.filter.MessageTypeFilter;
040import org.jivesoftware.smack.filter.StanzaExtensionFilter;
041import org.jivesoftware.smack.filter.StanzaFilter;
042import org.jivesoftware.smack.packet.ExtensionElement;
043import org.jivesoftware.smack.packet.Message;
044import org.jivesoftware.smack.packet.MessageBuilder;
045import org.jivesoftware.smack.packet.Stanza;
046import org.jivesoftware.smack.packet.StanzaBuilder;
047
048import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension;
049import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
050
051import org.jxmpp.jid.EntityBareJid;
052import org.jxmpp.jid.EntityFullJid;
053
054/**
055 * Handles chat state for all chats on a particular XMPPConnection. This class manages both the
056 * stanza extensions and the disco response necessary for compliance with
057 * <a href="http://www.xmpp.org/extensions/xep-0085.html">XEP-0085</a>.
058 *
059 * NOTE: {@link org.jivesoftware.smackx.chatstates.ChatStateManager#getInstance(org.jivesoftware.smack.XMPPConnection)}
060 * needs to be called in order for the listeners to be registered appropriately with the connection.
061 * If this does not occur you will not receive the update notifications.
062 *
063 * @author Alexander Wenckus
064 * @author Paul Schaub
065 * @see org.jivesoftware.smackx.chatstates.ChatState
066 * @see org.jivesoftware.smackx.chatstates.packet.ChatStateExtension
067 */
068public final class ChatStateManager extends Manager {
069
070    private static final Logger LOGGER = Logger.getLogger(ChatStateManager.class.getName());
071
072    public static final String NAMESPACE = "http://jabber.org/protocol/chatstates";
073
074    private static final Map<XMPPConnection, ChatStateManager> INSTANCES = new WeakHashMap<>();
075
076    private static final StanzaFilter INCOMING_MESSAGE_FILTER =
077            new AndFilter(MessageTypeFilter.NORMAL_OR_CHAT, FromTypeFilter.ENTITY_FULL_JID);
078    private static final StanzaFilter INCOMING_CHAT_STATE_FILTER = new AndFilter(INCOMING_MESSAGE_FILTER, new StanzaExtensionFilter(NAMESPACE));
079
080    /**
081     * Registered ChatStateListeners
082     */
083    private final Set<ChatStateListener> chatStateListeners = new HashSet<>();
084
085    /**
086     * Maps chat to last chat state.
087     */
088    private final Map<Chat, ChatState> chatStates = new WeakHashMap<>();
089
090    private final AsyncButOrdered<Chat> asyncButOrdered = new AsyncButOrdered<>();
091
092    /**
093     * Returns the ChatStateManager related to the XMPPConnection and it will create one if it does
094     * not yet exist.
095     *
096     * @param connection the connection to return the ChatStateManager
097     * @return the ChatStateManager related the the connection.
098     */
099    public static synchronized ChatStateManager getInstance(final XMPPConnection connection) {
100            ChatStateManager manager = INSTANCES.get(connection);
101            if (manager == null) {
102                manager = new ChatStateManager(connection);
103                INSTANCES.put(connection, manager);
104            }
105            return manager;
106    }
107
108    /**
109     * Private constructor to create a new ChatStateManager.
110     * This adds ChatMessageListeners as interceptors to the connection and adds the namespace to the disco features.
111     *
112     * @param connection xmpp connection
113     */
114    private ChatStateManager(XMPPConnection connection) {
115        super(connection);
116        ChatManager chatManager = ChatManager.getInstanceFor(connection);
117        chatManager.addOutgoingListener(new OutgoingChatMessageListener() {
118            @Override
119            public void newOutgoingMessage(EntityBareJid to, MessageBuilder message, Chat chat) {
120                if (chat == null) {
121                    return;
122                }
123
124                // if message already has a chatStateExtension, then do nothing,
125                if (message.hasExtension(ChatStateExtension.NAMESPACE)) {
126                    return;
127                }
128
129                // otherwise add a chatState extension if necessary.
130                if (updateChatState(chat, ChatState.active)) {
131                    message.addExtension(new ChatStateExtension(ChatState.active));
132                }
133            }
134        });
135
136        connection.addSyncStanzaListener(new StanzaListener() {
137            @Override
138            public void processStanza(Stanza stanza) {
139                final Message message = (Message) stanza;
140
141                EntityFullJid fullFrom = message.getFrom().asEntityFullJidIfPossible();
142                EntityBareJid bareFrom = fullFrom.asEntityBareJid();
143
144                final Chat chat = ChatManager.getInstanceFor(connection()).chatWith(bareFrom);
145                ExtensionElement extension = message.getExtension(NAMESPACE);
146                String chatStateElementName = extension.getElementName();
147
148                ChatState state;
149                try {
150                    state = ChatState.valueOf(chatStateElementName);
151                }
152                catch (Exception ex) {
153                    LOGGER.log(Level.WARNING, "Invalid chat state element name: " + chatStateElementName, ex);
154                    return;
155                }
156                final ChatState finalState = state;
157
158                List<ChatStateListener> listeners;
159                synchronized (chatStateListeners) {
160                    listeners = new ArrayList<>(chatStateListeners.size());
161                    listeners.addAll(chatStateListeners);
162                }
163
164                final List<ChatStateListener> finalListeners = listeners;
165                asyncButOrdered.performAsyncButOrdered(chat, new Runnable() {
166                    @Override
167                    public void run() {
168                        for (ChatStateListener listener : finalListeners) {
169                            listener.stateChanged(chat, finalState, message);
170                        }
171                    }
172                });
173            }
174        }, INCOMING_CHAT_STATE_FILTER);
175
176        ServiceDiscoveryManager.getInstanceFor(connection).addFeature(NAMESPACE);
177    }
178
179    /**
180     * Register a ChatStateListener. That listener will be informed about changed chat states.
181     *
182     * @param listener chatStateListener
183     * @return true, if the listener was not registered before
184     */
185    public boolean addChatStateListener(ChatStateListener listener) {
186        synchronized (chatStateListeners) {
187            return chatStateListeners.add(listener);
188        }
189    }
190
191    /**
192     * Unregister a ChatStateListener.
193     *
194     * @param listener chatStateListener
195     * @return true, if the listener was registered before
196     */
197    public boolean removeChatStateListener(ChatStateListener listener) {
198        synchronized (chatStateListeners) {
199            return chatStateListeners.remove(listener);
200        }
201    }
202
203
204    /**
205     * Sets the current state of the provided chat. This method will send an empty bodied Message
206     * stanza with the state attached as a {@link org.jivesoftware.smack.packet.ExtensionElement}, if
207     * and only if the new chat state is different than the last state.
208     *
209     * @param newState the new state of the chat
210     * @param chat the chat.
211     * @throws NotConnectedException if the XMPP connection is not connected.
212     * @throws InterruptedException if the calling thread was interrupted.
213     */
214    public void setCurrentState(ChatState newState, Chat chat) throws NotConnectedException, InterruptedException {
215        if (chat == null || newState == null) {
216            throw new IllegalArgumentException("Arguments cannot be null.");
217        }
218        if (!updateChatState(chat, newState)) {
219            return;
220        }
221        Message message = StanzaBuilder.buildMessage().build();
222        ChatStateExtension extension = new ChatStateExtension(newState);
223        message.addExtension(extension);
224
225        chat.send(message);
226    }
227
228
229    @Override
230    public boolean equals(Object o) {
231        if (this == o) return true;
232        if (o == null || getClass() != o.getClass()) return false;
233
234        ChatStateManager that = (ChatStateManager) o;
235
236        return connection().equals(that.connection());
237
238    }
239
240    @Override
241    public int hashCode() {
242        return connection().hashCode();
243    }
244
245    private synchronized boolean updateChatState(Chat chat, ChatState newState) {
246        ChatState lastChatState = chatStates.get(chat);
247        if (lastChatState != newState) {
248            chatStates.put(chat, newState);
249            return true;
250        }
251        return false;
252    }
253
254}