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