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