ChatManager.java

/**
 *
 * Copyright 2017-2019 Florian Schmaus.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jivesoftware.smack.chat2;

import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

import org.jivesoftware.smack.AsyncButOrdered;
import org.jivesoftware.smack.Manager;
import org.jivesoftware.smack.StanzaListener;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.filter.AndFilter;
import org.jivesoftware.smack.filter.FromTypeFilter;
import org.jivesoftware.smack.filter.MessageTypeFilter;
import org.jivesoftware.smack.filter.MessageWithBodiesFilter;
import org.jivesoftware.smack.filter.OrFilter;
import org.jivesoftware.smack.filter.StanzaExtensionFilter;
import org.jivesoftware.smack.filter.StanzaFilter;
import org.jivesoftware.smack.filter.ToTypeFilter;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.MessageView;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.roster.AbstractRosterListener;
import org.jivesoftware.smack.roster.Roster;

import org.jivesoftware.smackx.xhtmlim.packet.XHTMLExtension;

import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.EntityFullJid;
import org.jxmpp.jid.Jid;

/**
 * A chat manager for 1:1 XMPP instant messaging chats.
 * <p>
 * This manager and the according {@link Chat} API implement "Resource Locking" (XEP-0296). Support for Carbon Copies
 * (XEP-0280) will be added once the XEP has progressed from experimental.
 * </p>
 *
 * @see <a href="https://xmpp.org/extensions/xep-0296.html">XEP-0296: Best Practices for Resource Locking</a>
 */
public final class ChatManager extends Manager {

    private static final Map<XMPPConnection, ChatManager> INSTANCES = new WeakHashMap<>();

    public static synchronized ChatManager getInstanceFor(XMPPConnection connection) {
        ChatManager chatManager = INSTANCES.get(connection);
        if (chatManager == null) {
            chatManager = new ChatManager(connection);
            INSTANCES.put(connection, chatManager);
        }
        return chatManager;
    }

    // @FORMATTER:OFF
    private static final StanzaFilter MESSAGE_FILTER = new AndFilter(
                    MessageTypeFilter.NORMAL_OR_CHAT,
                    new OrFilter(MessageWithBodiesFilter.INSTANCE, new StanzaExtensionFilter(XHTMLExtension.ELEMENT, XHTMLExtension.NAMESPACE))
                    );

    private static final StanzaFilter OUTGOING_MESSAGE_FILTER = new AndFilter(
                    MESSAGE_FILTER,
                    ToTypeFilter.ENTITY_FULL_OR_BARE_JID
                    );

    private static final StanzaFilter INCOMING_MESSAGE_FILTER = new AndFilter(
                    MESSAGE_FILTER,
                    FromTypeFilter.ENTITY_FULL_JID
                    );
    // @FORMATTER:ON

    private final Map<EntityBareJid, Chat> chats = new ConcurrentHashMap<>();

    private final Set<IncomingChatMessageListener> incomingListeners = new CopyOnWriteArraySet<>();

    private final Set<OutgoingChatMessageListener> outgoingListeners = new CopyOnWriteArraySet<>();

    private final AsyncButOrdered<Chat> asyncButOrdered = new AsyncButOrdered<>();

    private boolean xhtmlIm;

    private ChatManager(final XMPPConnection connection) {
        super(connection);
        connection.addSyncStanzaListener(new StanzaListener() {
            @Override
            public void processStanza(Stanza stanza) {
                final Message message = (Message) stanza;
                if (!shouldAcceptMessage(message)) {
                    return;
                }

                final Jid from = message.getFrom();
                final EntityFullJid fullFrom = from.asEntityFullJidOrThrow();
                final EntityBareJid bareFrom = fullFrom.asEntityBareJid();
                final Chat chat = chatWith(bareFrom);
                chat.lockedResource = fullFrom;

                asyncButOrdered.performAsyncButOrdered(chat, new Runnable() {
                    @Override
                    public void run() {
                        for (IncomingChatMessageListener listener : incomingListeners) {
                            listener.newIncomingMessage(bareFrom, message, chat);
                        }
                    }
                });

            }
        }, INCOMING_MESSAGE_FILTER);

        connection.addMessageInterceptor(messageBuilder -> {
            if (!shouldAcceptMessage(messageBuilder)) {
                return;
            }

            final EntityBareJid to = messageBuilder.getTo().asEntityBareJidOrThrow();
            final Chat chat = chatWith(to);

            for (OutgoingChatMessageListener listener : outgoingListeners) {
                listener.newOutgoingMessage(to, messageBuilder, chat);
            }
        }, m -> {
            return OUTGOING_MESSAGE_FILTER.accept(m);
        });

        Roster roster = Roster.getInstanceFor(connection);
        roster.addRosterListener(new AbstractRosterListener() {
            @Override
            public void presenceChanged(Presence presence) {
                final Jid from = presence.getFrom();
                final EntityBareJid bareFrom = from.asEntityBareJidIfPossible();
                if (bareFrom == null) {
                    return;
                }

                final Chat chat = chats.get(bareFrom);
                if (chat == null) {
                    return;
                }

                if (chat.lockedResource == null) {
                    // According to XEP-0296, no action is required for resource locking upon receiving a presence if no
                    // resource is currently locked.
                    return;
                }

                final EntityFullJid fullFrom = from.asEntityFullJidIfPossible();
                if (!chat.lockedResource.equals(fullFrom)) {
                    return;
                }

                if (chat.lastPresenceOfLockedResource == null) {
                    // We have no last known presence from the locked resource.
                    chat.lastPresenceOfLockedResource = presence;
                    return;
                }

                if (chat.lastPresenceOfLockedResource.getMode() != presence.getMode()
                                || chat.lastPresenceOfLockedResource.getType() != presence.getType()) {
                    chat.unlockResource();
                }
            }
        });
    }

    private boolean shouldAcceptMessage(MessageView message) {
        if (message.hasExtension(Message.Body.QNAME)) {
            return true;
        }

        // Message has no XMPP-IM bodies, abort here if xhtmlIm is not enabled.
        if (!xhtmlIm) {
            return false;
        }

        XHTMLExtension xhtmlExtension = XHTMLExtension.from(message);
        if (xhtmlExtension == null) {
            // Message has no XHTML-IM extension, abort.
            return false;
        }
        return true;
    }

    /**
     * Add a new listener for incoming chat messages.
     *
     * @param listener the listener to add.
     * @return <code>true</code> if the listener was not already added.
     */
    public boolean addIncomingListener(IncomingChatMessageListener listener) {
        return incomingListeners.add(listener);
    }

    /**
     * Remove an incoming chat message listener.
     *
     * @param listener the listener to remove.
     * @return <code>true</code> if the listener was active and got removed.
     */
    public boolean removeIncomingListener(IncomingChatMessageListener listener) {
        return incomingListeners.remove(listener);
    }

    /**
     * Add a new listener for outgoing chat messages.
     *
     * @param listener the listener to add.
     * @return <code>true</code> if the listener was not already added.
     */
    public boolean addOutgoingListener(OutgoingChatMessageListener listener) {
        return outgoingListeners.add(listener);
    }

    /**
     * Remove an outgoing chat message listener.
     *
     * @param listener the listener to remove.
     * @return <code>true</code> if the listener was active and got removed.
     */
    public boolean removeOutgoingListener(OutgoingChatMessageListener listener) {
        return outgoingListeners.remove(listener);
    }

    /**
     * Start a new or retrieve the existing chat with <code>jid</code>.
     *
     * @param jid the XMPP address of the other entity to chat with.
     * @return the Chat API for the given XMPP address.
     */
    public Chat chatWith(EntityBareJid jid) {
        Chat chat = chats.get(jid);
        if (chat == null) {
            synchronized (chats) {
                // Double-checked locking.
                chat = chats.get(jid);
                if (chat != null) {
                    return chat;
                }
                chat = new Chat(connection(), jid);
                chats.put(jid, chat);
            }
        }
        return chat;
    }

    /**
     * Also notify about messages containing XHTML-IM.
     *
     * @param xhtmlIm TODO javadoc me please
     */
    public void setXhmtlImEnabled(boolean xhtmlIm) {
        this.xhtmlIm = xhtmlIm;
    }
}