001/**
002 *
003 * Copyright 2017 Florian Schmaus.
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 */
017package org.jivesoftware.smack.chat2;
018
019import java.util.Map;
020import java.util.Set;
021import java.util.WeakHashMap;
022import java.util.concurrent.ConcurrentHashMap;
023import java.util.concurrent.CopyOnWriteArraySet;
024
025import org.jivesoftware.smack.Manager;
026import org.jivesoftware.smack.SmackException.NotConnectedException;
027import org.jivesoftware.smack.StanzaListener;
028import org.jivesoftware.smack.XMPPConnection;
029import org.jivesoftware.smack.filter.AndFilter;
030import org.jivesoftware.smack.filter.FromTypeFilter;
031import org.jivesoftware.smack.filter.MessageTypeFilter;
032import org.jivesoftware.smack.filter.MessageWithBodiesFilter;
033import org.jivesoftware.smack.filter.OrFilter;
034import org.jivesoftware.smack.filter.StanzaExtensionFilter;
035import org.jivesoftware.smack.filter.StanzaFilter;
036import org.jivesoftware.smack.filter.ToTypeFilter;
037import org.jivesoftware.smack.packet.Message;
038import org.jivesoftware.smack.packet.Presence;
039import org.jivesoftware.smack.packet.Stanza;
040import org.jivesoftware.smack.roster.AbstractRosterListener;
041import org.jivesoftware.smack.roster.Roster;
042
043import org.jivesoftware.smackx.xhtmlim.packet.XHTMLExtension;
044
045import org.jxmpp.jid.EntityBareJid;
046import org.jxmpp.jid.EntityFullJid;
047import org.jxmpp.jid.Jid;
048
049/**
050 * A chat manager for 1:1 XMPP instant messaging chats.
051 * <p>
052 * This manager and the according {@link Chat} API implement "Resource Locking" (XEP-0296). Support for Carbon Copies
053 * (XEP-0280) will be added once the XEP has progressed from experimental.
054 * </p>
055 *
056 * @see <a href="https://xmpp.org/extensions/xep-0296.html">XEP-0296: Best Practices for Resource Locking</a>
057 */
058@SuppressWarnings("FunctionalInterfaceClash")
059public final class ChatManager extends Manager {
060
061    private static final Map<XMPPConnection, ChatManager> INSTANCES = new WeakHashMap<>();
062
063    public static synchronized ChatManager getInstanceFor(XMPPConnection connection) {
064        ChatManager chatManager = INSTANCES.get(connection);
065        if (chatManager == null) {
066            chatManager = new ChatManager(connection);
067            INSTANCES.put(connection, chatManager);
068        }
069        return chatManager;
070    }
071
072    // @FORMATTER:OFF
073    private static final StanzaFilter MESSAGE_FILTER = new AndFilter(
074                    MessageTypeFilter.NORMAL_OR_CHAT,
075                    new OrFilter(MessageWithBodiesFilter.INSTANCE, new StanzaExtensionFilter(XHTMLExtension.ELEMENT, XHTMLExtension.NAMESPACE))
076                    );
077
078    private static final StanzaFilter OUTGOING_MESSAGE_FILTER = new AndFilter(
079                    MESSAGE_FILTER,
080                    ToTypeFilter.ENTITY_FULL_OR_BARE_JID
081                    );
082
083    private static final StanzaFilter INCOMING_MESSAGE_FILTER = new AndFilter(
084                    MESSAGE_FILTER,
085                    FromTypeFilter.ENTITY_FULL_JID
086                    );
087    // @FORMATTER:ON
088
089    private final Map<EntityBareJid, Chat> chats = new ConcurrentHashMap<>();
090
091    private final Set<IncomingChatMessageListener> incomingListeners = new CopyOnWriteArraySet<>();
092
093    private final Set<OutgoingChatMessageListener> outgoingListeners = new CopyOnWriteArraySet<>();
094
095    private boolean xhtmlIm;
096
097    private ChatManager(final XMPPConnection connection) {
098        super(connection);
099        connection.addSyncStanzaListener(new StanzaListener() {
100            @Override
101            public void processStanza(Stanza stanza) {
102                Message message = (Message) stanza;
103                if (!shouldAcceptMessage(message)) {
104                    return;
105                }
106
107                final Jid from = message.getFrom();
108                final EntityFullJid fullFrom = from.asEntityFullJidOrThrow();
109                final EntityBareJid bareFrom = fullFrom.asEntityBareJid();
110                final Chat chat = chatWith(bareFrom);
111                chat.lockedResource = fullFrom;
112
113                for (IncomingChatMessageListener listener : incomingListeners) {
114                    listener.newIncomingMessage(bareFrom, message, chat);
115                }
116            }
117        }, INCOMING_MESSAGE_FILTER);
118
119        connection.addPacketInterceptor(new StanzaListener() {
120            @Override
121            public void processStanza(Stanza stanza) throws NotConnectedException, InterruptedException {
122                Message message = (Message) stanza;
123                if (!shouldAcceptMessage(message)) {
124                    return;
125                }
126
127                final EntityBareJid to = message.getTo().asEntityBareJidOrThrow();
128                final Chat chat = chatWith(to);
129
130                for (OutgoingChatMessageListener listener : outgoingListeners) {
131                    listener.newOutgoingMessage(to, message, chat);
132                }
133            }
134        }, OUTGOING_MESSAGE_FILTER);
135
136        Roster roster = Roster.getInstanceFor(connection);
137        roster.addRosterListener(new AbstractRosterListener() {
138            @Override
139            public void presenceChanged(Presence presence) {
140                final Jid from = presence.getFrom();
141                final EntityBareJid bareFrom = from.asEntityBareJidIfPossible();
142                if (bareFrom == null) {
143                    return;
144                }
145
146                final Chat chat = chats.get(bareFrom);
147                if (chat == null) {
148                    return;
149                }
150
151                if (chat.lockedResource == null) {
152                    // According to XEP-0296, no action is required for resource locking upon receiving a presence if no
153                    // resource is currently locked.
154                    return;
155                }
156
157                final EntityFullJid fullFrom = from.asEntityFullJidIfPossible();
158                if (!chat.lockedResource.equals(fullFrom)) {
159                    return;
160                }
161
162                if (chat.lastPresenceOfLockedResource == null) {
163                    // We have no last known presence from the locked resource.
164                    chat.lastPresenceOfLockedResource = presence;
165                    return;
166                }
167
168                if (chat.lastPresenceOfLockedResource.getMode() != presence.getMode()
169                                || chat.lastPresenceOfLockedResource.getType() != presence.getType()) {
170                    chat.unlockResource();
171                }
172            }
173        });
174    }
175
176    private boolean shouldAcceptMessage(Message message) {
177        if (!message.getBodies().isEmpty()) {
178            return true;
179        }
180
181        // Message has no XMPP-IM bodies, abort here if xhtmlIm is not enabled.
182        if (!xhtmlIm) {
183            return false;
184        }
185
186        XHTMLExtension xhtmlExtension = XHTMLExtension.from(message);
187        if (xhtmlExtension == null) {
188            // Message has no XHTML-IM extension, abort.
189            return false;
190        }
191        return true;
192    }
193
194    /**
195     * Add a new listener for incoming chat messages.
196     *
197     * @param listener the listener to add.
198     * @return <code>true</code> if the listener was not already added.
199     */
200    public boolean addIncomingListener(IncomingChatMessageListener listener) {
201        return incomingListeners.add(listener);
202    }
203
204    /**
205     * Add a new listener for incoming chat messages.
206     *
207     * @param listener the listener to add.
208     * @return <code>true</code> if the listener was not already added.
209     */
210    @Deprecated
211    @SuppressWarnings("FunctionalInterfaceClash")
212    public boolean addListener(IncomingChatMessageListener listener) {
213        return addIncomingListener(listener);
214    }
215
216    /**
217     * Remove an incoming chat message listener.
218     *
219     * @param listener the listener to remove.
220     * @return <code>true</code> if the listener was active and got removed.
221     */
222    @SuppressWarnings("FunctionalInterfaceClash")
223    public boolean removeListener(IncomingChatMessageListener listener) {
224        return incomingListeners.remove(listener);
225    }
226
227    /**
228     * Add a new listener for outgoing chat messages.
229     *
230     * @param listener the listener to add.
231     * @return <code>true</code> if the listener was not already added.
232     */
233    public boolean addOutgoingListener(OutgoingChatMessageListener listener) {
234        return outgoingListeners.add(listener);
235    }
236
237    /**
238     * Add a new listener for incoming chat messages.
239     *
240     * @param listener the listener to add.
241     * @return <code>true</code> if the listener was not already added.
242     * @deprecated use {@link #addOutgoingListener(OutgoingChatMessageListener)} instead.
243     */
244    @Deprecated
245    @SuppressWarnings("FunctionalInterfaceClash")
246    public boolean addListener(OutgoingChatMessageListener listener) {
247        return addOutgoingListener(listener);
248    }
249
250    /**
251     * Remove an outgoing chat message listener.
252     *
253     * @param listener the listener to remove.
254     * @return <code>true</code> if the listener was active and got removed.
255     */
256    public boolean removeListener(OutgoingChatMessageListener listener) {
257        return outgoingListeners.remove(listener);
258    }
259
260    /**
261     * Remove an outgoing chat message listener.
262     *
263     * @param listener the listener to remove.
264     * @return <code>true</code> if the listener was active and got removed.
265     * @deprecated use {@link #removeListener(OutgoingChatMessageListener)} instead.
266     */
267    @Deprecated
268    public boolean removeOutoingLIstener(OutgoingChatMessageListener listener) {
269        return removeListener(listener);
270    }
271
272    /**
273     * Start a new or retrieve the existing chat with <code>jid</code>.
274     *
275     * @param jid the XMPP address of the other entity to chat with.
276     * @return the Chat API for the given XMPP address.
277     */
278    public Chat chatWith(EntityBareJid jid) {
279        Chat chat = chats.get(jid);
280        if (chat == null) {
281            synchronized (chats) {
282                // Double-checked locking.
283                chat = chats.get(jid);
284                if (chat != null) {
285                    return chat;
286                }
287                chat = new Chat(connection(), jid);
288                chats.put(jid, chat);
289            }
290        }
291        return chat;
292    }
293
294    /**
295     * Also notify about messages containing XHTML-IM.
296     *
297     * @param xhtmlIm
298     */
299    public void setXhmtlImEnabled(boolean xhtmlIm) {
300        this.xhtmlIm = xhtmlIm;
301    }
302}