001/**
002 *
003 * Copyright 2017-2019 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.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.MessageView;
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.addMessageInterceptor(messageBuilder -> {
128            if (!shouldAcceptMessage(messageBuilder)) {
129                return;
130            }
131
132            final EntityBareJid to = messageBuilder.getTo().asEntityBareJidOrThrow();
133            final Chat chat = chatWith(to);
134
135            for (OutgoingChatMessageListener listener : outgoingListeners) {
136                listener.newOutgoingMessage(to, messageBuilder, chat);
137            }
138        }, m -> {
139            return OUTGOING_MESSAGE_FILTER.accept(m);
140        });
141
142        Roster roster = Roster.getInstanceFor(connection);
143        roster.addRosterListener(new AbstractRosterListener() {
144            @Override
145            public void presenceChanged(Presence presence) {
146                final Jid from = presence.getFrom();
147                final EntityBareJid bareFrom = from.asEntityBareJidIfPossible();
148                if (bareFrom == null) {
149                    return;
150                }
151
152                final Chat chat = chats.get(bareFrom);
153                if (chat == null) {
154                    return;
155                }
156
157                if (chat.lockedResource == null) {
158                    // According to XEP-0296, no action is required for resource locking upon receiving a presence if no
159                    // resource is currently locked.
160                    return;
161                }
162
163                final EntityFullJid fullFrom = from.asEntityFullJidIfPossible();
164                if (chat.lockedResource.equals(fullFrom)) {
165                    return;
166                }
167
168                if (chat.lastPresenceOfLockedResource == null) {
169                    // We have no last known presence from the locked resource.
170                    chat.lastPresenceOfLockedResource = presence;
171                    return;
172                }
173
174                if (chat.lastPresenceOfLockedResource.getMode() != presence.getMode()
175                                || chat.lastPresenceOfLockedResource.getType() != presence.getType()) {
176                    chat.unlockResource();
177                }
178            }
179        });
180    }
181
182    private boolean shouldAcceptMessage(MessageView message) {
183        if (message.hasExtension(Message.Body.QNAME)) {
184            return true;
185        }
186
187        // Message has no XMPP-IM bodies, abort here if xhtmlIm is not enabled.
188        if (!xhtmlIm) {
189            return false;
190        }
191
192        XHTMLExtension xhtmlExtension = XHTMLExtension.from(message);
193        if (xhtmlExtension == null) {
194            // Message has no XHTML-IM extension, abort.
195            return false;
196        }
197        return true;
198    }
199
200    /**
201     * Add a new listener for incoming chat messages.
202     *
203     * @param listener the listener to add.
204     * @return <code>true</code> if the listener was not already added.
205     */
206    public boolean addIncomingListener(IncomingChatMessageListener listener) {
207        return incomingListeners.add(listener);
208    }
209
210    /**
211     * Remove an incoming chat message listener.
212     *
213     * @param listener the listener to remove.
214     * @return <code>true</code> if the listener was active and got removed.
215     */
216    public boolean removeIncomingListener(IncomingChatMessageListener listener) {
217        return incomingListeners.remove(listener);
218    }
219
220    /**
221     * Add a new listener for outgoing chat messages.
222     *
223     * @param listener the listener to add.
224     * @return <code>true</code> if the listener was not already added.
225     */
226    public boolean addOutgoingListener(OutgoingChatMessageListener listener) {
227        return outgoingListeners.add(listener);
228    }
229
230    /**
231     * Remove an outgoing chat message listener.
232     *
233     * @param listener the listener to remove.
234     * @return <code>true</code> if the listener was active and got removed.
235     */
236    public boolean removeOutgoingListener(OutgoingChatMessageListener listener) {
237        return outgoingListeners.remove(listener);
238    }
239
240    /**
241     * Start a new or retrieve the existing chat with <code>jid</code>.
242     *
243     * @param jid the XMPP address of the other entity to chat with.
244     * @return the Chat API for the given XMPP address.
245     */
246    public Chat chatWith(EntityBareJid jid) {
247        Chat chat = chats.get(jid);
248        if (chat == null) {
249            synchronized (chats) {
250                // Double-checked locking.
251                chat = chats.get(jid);
252                if (chat != null) {
253                    return chat;
254                }
255                chat = new Chat(connection(), jid);
256                chats.put(jid, chat);
257            }
258        }
259        return chat;
260    }
261
262    /**
263     * Also notify about messages containing XHTML-IM.
264     *
265     * @param xhtmlIm TODO javadoc me please
266     */
267    public void setXhmtlImEnabled(boolean xhtmlIm) {
268        this.xhtmlIm = xhtmlIm;
269    }
270}