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}