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