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