001/**
002 *
003 * Copyright 2017-2018 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.AsyncButOrdered;
026import org.jivesoftware.smack.Manager;
027import org.jivesoftware.smack.SmackException.NotConnectedException;
028import org.jivesoftware.smack.StanzaListener;
029import org.jivesoftware.smack.XMPPConnection;
030import org.jivesoftware.smack.filter.AndFilter;
031import org.jivesoftware.smack.filter.FromTypeFilter;
032import org.jivesoftware.smack.filter.MessageTypeFilter;
033import org.jivesoftware.smack.filter.MessageWithBodiesFilter;
034import org.jivesoftware.smack.filter.OrFilter;
035import org.jivesoftware.smack.filter.StanzaExtensionFilter;
036import org.jivesoftware.smack.filter.StanzaFilter;
037import org.jivesoftware.smack.filter.ToTypeFilter;
038import org.jivesoftware.smack.packet.Message;
039import org.jivesoftware.smack.packet.Presence;
040import org.jivesoftware.smack.packet.Stanza;
041import org.jivesoftware.smack.roster.AbstractRosterListener;
042import org.jivesoftware.smack.roster.Roster;
043
044import org.jivesoftware.smackx.xhtmlim.packet.XHTMLExtension;
045
046import org.jxmpp.jid.EntityBareJid;
047import org.jxmpp.jid.EntityFullJid;
048import org.jxmpp.jid.Jid;
049
050/**
051 * A chat manager for 1:1 XMPP instant messaging chats.
052 * <p>
053 * This manager and the according {@link Chat} API implement "Resource Locking" (XEP-0296). Support for Carbon Copies
054 * (XEP-0280) will be added once the XEP has progressed from experimental.
055 * </p>
056 *
057 * @see <a href="https://xmpp.org/extensions/xep-0296.html">XEP-0296: Best Practices for Resource Locking</a>
058 */
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 final AsyncButOrdered<Chat> asyncButOrdered = new AsyncButOrdered<>();
096
097    private boolean xhtmlIm;
098
099    private ChatManager(final XMPPConnection connection) {
100        super(connection);
101        connection.addSyncStanzaListener(new StanzaListener() {
102            @Override
103            public void processStanza(Stanza stanza) {
104                final Message message = (Message) stanza;
105                if (!shouldAcceptMessage(message)) {
106                    return;
107                }
108
109                final Jid from = message.getFrom();
110                final EntityFullJid fullFrom = from.asEntityFullJidOrThrow();
111                final EntityBareJid bareFrom = fullFrom.asEntityBareJid();
112                final Chat chat = chatWith(bareFrom);
113                chat.lockedResource = fullFrom;
114
115                asyncButOrdered.performAsyncButOrdered(chat, new Runnable() {
116                    @Override
117                    public void run() {
118                        for (IncomingChatMessageListener listener : incomingListeners) {
119                            listener.newIncomingMessage(bareFrom, message, chat);
120                        }
121                    }
122                });
123
124            }
125        }, INCOMING_MESSAGE_FILTER);
126
127        connection.addStanzaInterceptor(new StanzaListener() {
128            @Override
129            public void processStanza(Stanza stanza) throws NotConnectedException, InterruptedException {
130                Message message = (Message) stanza;
131                if (!shouldAcceptMessage(message)) {
132                    return;
133                }
134
135                final EntityBareJid to = message.getTo().asEntityBareJidOrThrow();
136                final Chat chat = chatWith(to);
137
138                for (OutgoingChatMessageListener listener : outgoingListeners) {
139                    listener.newOutgoingMessage(to, message, chat);
140                }
141            }
142        }, OUTGOING_MESSAGE_FILTER);
143
144        Roster roster = Roster.getInstanceFor(connection);
145        roster.addRosterListener(new AbstractRosterListener() {
146            @Override
147            public void presenceChanged(Presence presence) {
148                final Jid from = presence.getFrom();
149                final EntityBareJid bareFrom = from.asEntityBareJidIfPossible();
150                if (bareFrom == null) {
151                    return;
152                }
153
154                final Chat chat = chats.get(bareFrom);
155                if (chat == null) {
156                    return;
157                }
158
159                if (chat.lockedResource == null) {
160                    // According to XEP-0296, no action is required for resource locking upon receiving a presence if no
161                    // resource is currently locked.
162                    return;
163                }
164
165                final EntityFullJid fullFrom = from.asEntityFullJidIfPossible();
166                if (!chat.lockedResource.equals(fullFrom)) {
167                    return;
168                }
169
170                if (chat.lastPresenceOfLockedResource == null) {
171                    // We have no last known presence from the locked resource.
172                    chat.lastPresenceOfLockedResource = presence;
173                    return;
174                }
175
176                if (chat.lastPresenceOfLockedResource.getMode() != presence.getMode()
177                                || chat.lastPresenceOfLockedResource.getType() != presence.getType()) {
178                    chat.unlockResource();
179                }
180            }
181        });
182    }
183
184    private boolean shouldAcceptMessage(Message message) {
185        if (!message.getBodies().isEmpty()) {
186            return true;
187        }
188
189        // Message has no XMPP-IM bodies, abort here if xhtmlIm is not enabled.
190        if (!xhtmlIm) {
191            return false;
192        }
193
194        XHTMLExtension xhtmlExtension = XHTMLExtension.from(message);
195        if (xhtmlExtension == null) {
196            // Message has no XHTML-IM extension, abort.
197            return false;
198        }
199        return true;
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    public boolean addIncomingListener(IncomingChatMessageListener listener) {
209        return incomingListeners.add(listener);
210    }
211
212    /**
213     * Remove an incoming chat message listener.
214     *
215     * @param listener the listener to remove.
216     * @return <code>true</code> if the listener was active and got removed.
217     */
218    public boolean removeIncomingListener(IncomingChatMessageListener listener) {
219        return incomingListeners.remove(listener);
220    }
221
222    /**
223     * Add a new listener for outgoing chat messages.
224     *
225     * @param listener the listener to add.
226     * @return <code>true</code> if the listener was not already added.
227     */
228    public boolean addOutgoingListener(OutgoingChatMessageListener listener) {
229        return outgoingListeners.add(listener);
230    }
231
232    /**
233     * Remove an outgoing chat message listener.
234     *
235     * @param listener the listener to remove.
236     * @return <code>true</code> if the listener was active and got removed.
237     */
238    public boolean removeOutgoingListener(OutgoingChatMessageListener listener) {
239        return outgoingListeners.remove(listener);
240    }
241
242    /**
243     * Start a new or retrieve the existing chat with <code>jid</code>.
244     *
245     * @param jid the XMPP address of the other entity to chat with.
246     * @return the Chat API for the given XMPP address.
247     */
248    public Chat chatWith(EntityBareJid jid) {
249        Chat chat = chats.get(jid);
250        if (chat == null) {
251            synchronized (chats) {
252                // Double-checked locking.
253                chat = chats.get(jid);
254                if (chat != null) {
255                    return chat;
256                }
257                chat = new Chat(connection(), jid);
258                chats.put(jid, chat);
259            }
260        }
261        return chat;
262    }
263
264    /**
265     * Also notify about messages containing XHTML-IM.
266     *
267     * @param xhtmlIm
268     */
269    public void setXhmtlImEnabled(boolean xhtmlIm) {
270        this.xhtmlIm = xhtmlIm;
271    }
272}