001/** 002 * 003 * Copyright © 2014-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.smackx.muc; 018 019import java.lang.ref.WeakReference; 020import java.util.ArrayList; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.HashSet; 024import java.util.List; 025import java.util.Map; 026import java.util.Set; 027import java.util.WeakHashMap; 028import java.util.concurrent.CopyOnWriteArraySet; 029import java.util.logging.Level; 030import java.util.logging.Logger; 031 032import org.jivesoftware.smack.AbstractConnectionListener; 033import org.jivesoftware.smack.ConnectionCreationListener; 034import org.jivesoftware.smack.Manager; 035import org.jivesoftware.smack.StanzaListener; 036import org.jivesoftware.smack.XMPPConnection; 037import org.jivesoftware.smack.XMPPConnectionRegistry; 038import org.jivesoftware.smack.SmackException.NoResponseException; 039import org.jivesoftware.smack.SmackException.NotConnectedException; 040import org.jivesoftware.smack.XMPPException.XMPPErrorException; 041import org.jivesoftware.smack.filter.AndFilter; 042import org.jivesoftware.smack.filter.MessageTypeFilter; 043import org.jivesoftware.smack.filter.StanzaExtensionFilter; 044import org.jivesoftware.smack.filter.StanzaFilter; 045import org.jivesoftware.smack.filter.NotFilter; 046import org.jivesoftware.smack.filter.StanzaTypeFilter; 047import org.jivesoftware.smack.packet.Message; 048import org.jivesoftware.smack.packet.Stanza; 049import org.jivesoftware.smack.util.Async; 050import org.jivesoftware.smackx.disco.AbstractNodeInformationProvider; 051import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; 052import org.jivesoftware.smackx.disco.packet.DiscoverInfo; 053import org.jivesoftware.smackx.disco.packet.DiscoverItems; 054import org.jivesoftware.smackx.muc.MultiUserChatException.NotAMucServiceException; 055import org.jivesoftware.smackx.muc.packet.MUCInitialPresence; 056import org.jivesoftware.smackx.muc.packet.MUCUser; 057import org.jxmpp.jid.EntityBareJid; 058import org.jxmpp.jid.DomainBareJid; 059import org.jxmpp.jid.Jid; 060import org.jxmpp.jid.parts.Resourcepart; 061import org.jxmpp.jid.EntityJid; 062 063/** 064 * A manager for Multi-User Chat rooms. 065 * <p> 066 * Use {@link #getMultiUserChat(EntityBareJid)} to retrieve an object representing a Multi-User Chat room. 067 * </p> 068 * <p> 069 * <b>Automatic rejoin:</b> The manager supports automatic rejoin of MultiUserChat rooms once the connection got 070 * re-established. This mechanism is disabled by default. To enable it, use {@link #setAutoJoinOnReconnect(boolean)}. 071 * You can set a {@link AutoJoinFailedCallback} via {@link #setAutoJoinFailedCallback(AutoJoinFailedCallback)} to get 072 * notified if this mechanism failed for some reason. Note that as soon as rejoining for a single room failed, no 073 * further attempts will be made for the other rooms. 074 * </p> 075 * 076 * @see <a href="http://xmpp.org/extensions/xep-0045.html">XEP-0045: Multi-User Chat</a> 077 */ 078public final class MultiUserChatManager extends Manager { 079 private final static String DISCO_NODE = MUCInitialPresence.NAMESPACE + "#rooms"; 080 081 private static final Logger LOGGER = Logger.getLogger(MultiUserChatManager.class.getName()); 082 083 static { 084 XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { 085 @Override 086 public void connectionCreated(final XMPPConnection connection) { 087 // Set on every established connection that this client supports the Multi-User 088 // Chat protocol. This information will be used when another client tries to 089 // discover whether this client supports MUC or not. 090 ServiceDiscoveryManager.getInstanceFor(connection).addFeature(MUCInitialPresence.NAMESPACE); 091 092 // Set the NodeInformationProvider that will provide information about the 093 // joined rooms whenever a disco request is received 094 final WeakReference<XMPPConnection> weakRefConnection = new WeakReference<XMPPConnection>(connection); 095 ServiceDiscoveryManager.getInstanceFor(connection).setNodeInformationProvider(DISCO_NODE, 096 new AbstractNodeInformationProvider() { 097 @Override 098 public List<DiscoverItems.Item> getNodeItems() { 099 XMPPConnection connection = weakRefConnection.get(); 100 if (connection == null) 101 return Collections.emptyList(); 102 Set<EntityBareJid> joinedRooms = MultiUserChatManager.getInstanceFor(connection).getJoinedRooms(); 103 List<DiscoverItems.Item> answer = new ArrayList<DiscoverItems.Item>(); 104 for (EntityBareJid room : joinedRooms) { 105 answer.add(new DiscoverItems.Item(room)); 106 } 107 return answer; 108 } 109 }); 110 } 111 }); 112 } 113 114 private static final Map<XMPPConnection, MultiUserChatManager> INSTANCES = new WeakHashMap<XMPPConnection, MultiUserChatManager>(); 115 116 /** 117 * Get a instance of a multi user chat manager for the given connection. 118 * 119 * @param connection 120 * @return a multi user chat manager. 121 */ 122 public static synchronized MultiUserChatManager getInstanceFor(XMPPConnection connection) { 123 MultiUserChatManager multiUserChatManager = INSTANCES.get(connection); 124 if (multiUserChatManager == null) { 125 multiUserChatManager = new MultiUserChatManager(connection); 126 INSTANCES.put(connection, multiUserChatManager); 127 } 128 return multiUserChatManager; 129 } 130 131 private static final StanzaFilter INVITATION_FILTER = new AndFilter(StanzaTypeFilter.MESSAGE, new StanzaExtensionFilter(new MUCUser()), 132 new NotFilter(MessageTypeFilter.ERROR)); 133 134 private final Set<InvitationListener> invitationsListeners = new CopyOnWriteArraySet<InvitationListener>(); 135 private final Set<EntityBareJid> joinedRooms = new HashSet<>(); 136 137 /** 138 * A Map of MUC JIDs to {@link MultiUserChat} instances. We use weak references for the values in order to allow 139 * those instances to get garbage collected. Note that MultiUserChat instances can not get garbage collected while 140 * the user is joined, because then the MUC will have PacketListeners added to the XMPPConnection. 141 */ 142 private final Map<EntityBareJid, WeakReference<MultiUserChat>> multiUserChats = new HashMap<>(); 143 144 private boolean autoJoinOnReconnect; 145 146 private AutoJoinFailedCallback autoJoinFailedCallback; 147 148 private MultiUserChatManager(XMPPConnection connection) { 149 super(connection); 150 // Listens for all messages that include a MUCUser extension and fire the invitation 151 // listeners if the message includes an invitation. 152 StanzaListener invitationPacketListener = new StanzaListener() { 153 @Override 154 public void processStanza(Stanza packet) { 155 final Message message = (Message) packet; 156 // Get the MUCUser extension 157 final MUCUser mucUser = MUCUser.from(message); 158 // Check if the MUCUser extension includes an invitation 159 if (mucUser.getInvite() != null) { 160 EntityBareJid mucJid = message.getFrom().asEntityBareJidIfPossible(); 161 if (mucJid == null) { 162 LOGGER.warning("Invite to non bare JID: '" + message.toXML() + "'"); 163 return; 164 } 165 // Fire event for invitation listeners 166 final MultiUserChat muc = getMultiUserChat(mucJid); 167 final XMPPConnection connection = connection(); 168 final MUCUser.Invite invite = mucUser.getInvite(); 169 final EntityJid from = invite.getFrom(); 170 final String reason = invite.getReason(); 171 final String password = mucUser.getPassword(); 172 for (final InvitationListener listener : invitationsListeners) { 173 listener.invitationReceived(connection, muc, from, reason, password, message, invite); 174 } 175 } 176 } 177 }; 178 connection.addAsyncStanzaListener(invitationPacketListener, INVITATION_FILTER); 179 180 connection.addConnectionListener(new AbstractConnectionListener() { 181 @Override 182 public void authenticated(XMPPConnection connection, boolean resumed) { 183 if (resumed) return; 184 if (!autoJoinOnReconnect) return; 185 186 final Set<EntityBareJid> mucs = getJoinedRooms(); 187 if (mucs.isEmpty()) return; 188 189 Async.go(new Runnable() { 190 @Override 191 public void run() { 192 final AutoJoinFailedCallback failedCallback = autoJoinFailedCallback; 193 for (EntityBareJid mucJid : mucs) { 194 MultiUserChat muc = getMultiUserChat(mucJid); 195 196 if (!muc.isJoined()) return; 197 198 Resourcepart nickname = muc.getNickname(); 199 if (nickname == null) return; 200 201 try { 202 muc.leave(); 203 } catch (NotConnectedException | InterruptedException e) { 204 if (failedCallback != null) { 205 failedCallback.autoJoinFailed(muc, e); 206 } else { 207 LOGGER.log(Level.WARNING, "Could not leave room", e); 208 } 209 return; 210 } 211 try { 212 muc.join(nickname); 213 } catch (NotAMucServiceException | NoResponseException | XMPPErrorException 214 | NotConnectedException | InterruptedException e) { 215 if (failedCallback != null) { 216 failedCallback.autoJoinFailed(muc, e); 217 } else { 218 LOGGER.log(Level.WARNING, "Could not leave room", e); 219 } 220 return; 221 } 222 } 223 } 224 225 }); 226 } 227 }); 228 } 229 230 /** 231 * Creates a multi user chat. Note: no information is sent to or received from the server until you attempt to 232 * {@link MultiUserChat#join(org.jxmpp.jid.parts.Resourcepart) join} the chat room. On some server implementations, the room will not be 233 * created until the first person joins it. 234 * <p> 235 * Most XMPP servers use a sub-domain for the chat service (eg chat.example.com for the XMPP server example.com). 236 * You must ensure that the room address you're trying to connect to includes the proper chat sub-domain. 237 * </p> 238 * 239 * @param jid the name of the room in the form "roomName@service", where "service" is the hostname at which the 240 * multi-user chat service is running. Make sure to provide a valid JID. 241 */ 242 public synchronized MultiUserChat getMultiUserChat(EntityBareJid jid) { 243 WeakReference<MultiUserChat> weakRefMultiUserChat = multiUserChats.get(jid); 244 if (weakRefMultiUserChat == null) { 245 return createNewMucAndAddToMap(jid); 246 } 247 MultiUserChat multiUserChat = weakRefMultiUserChat.get(); 248 if (multiUserChat == null) { 249 return createNewMucAndAddToMap(jid); 250 } 251 return multiUserChat; 252 } 253 254 private MultiUserChat createNewMucAndAddToMap(EntityBareJid jid) { 255 MultiUserChat multiUserChat = new MultiUserChat(connection(), jid, this); 256 multiUserChats.put(jid, new WeakReference<MultiUserChat>(multiUserChat)); 257 return multiUserChat; 258 } 259 260 /** 261 * Returns true if the specified user supports the Multi-User Chat protocol. 262 * 263 * @param user the user to check. A fully qualified xmpp ID, e.g. jdoe@example.com. 264 * @return a boolean indicating whether the specified user supports the MUC protocol. 265 * @throws XMPPErrorException 266 * @throws NoResponseException 267 * @throws NotConnectedException 268 * @throws InterruptedException 269 */ 270 public boolean isServiceEnabled(Jid user) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 271 return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(user, MUCInitialPresence.NAMESPACE); 272 } 273 274 /** 275 * Returns a Set of the rooms where the user has joined. The Iterator will contain Strings where each String 276 * represents a room (e.g. room@muc.jabber.org). 277 * 278 * @return a List of the rooms where the user has joined using a given connection. 279 */ 280 public Set<EntityBareJid> getJoinedRooms() { 281 return Collections.unmodifiableSet(joinedRooms); 282 } 283 284 /** 285 * Returns a List of the rooms where the requested user has joined. The Iterator will contain Strings where each 286 * String represents a room (e.g. room@muc.jabber.org). 287 * 288 * @param user the user to check. A fully qualified xmpp ID, e.g. jdoe@example.com. 289 * @return a List of the rooms where the requested user has joined. 290 * @throws XMPPErrorException 291 * @throws NoResponseException 292 * @throws NotConnectedException 293 * @throws InterruptedException 294 */ 295 public List<EntityBareJid> getJoinedRooms(EntityJid user) throws NoResponseException, XMPPErrorException, 296 NotConnectedException, InterruptedException { 297 // Send the disco packet to the user 298 DiscoverItems result = ServiceDiscoveryManager.getInstanceFor(connection()).discoverItems(user, DISCO_NODE); 299 List<DiscoverItems.Item> items = result.getItems(); 300 List<EntityBareJid> answer = new ArrayList<>(items.size()); 301 // Collect the entityID for each returned item 302 for (DiscoverItems.Item item : items) { 303 EntityBareJid muc = item.getEntityID().asEntityBareJidIfPossible(); 304 if (muc == null) { 305 LOGGER.warning("Not a bare JID: " + item.getEntityID()); 306 continue; 307 } 308 answer.add(muc); 309 } 310 return answer; 311 } 312 313 /** 314 * Returns the discovered information of a given room without actually having to join the room. The server will 315 * provide information only for rooms that are public. 316 * 317 * @param room the name of the room in the form "roomName@service" of which we want to discover its information. 318 * @return the discovered information of a given room without actually having to join the room. 319 * @throws XMPPErrorException 320 * @throws NoResponseException 321 * @throws NotConnectedException 322 * @throws InterruptedException 323 */ 324 public RoomInfo getRoomInfo(EntityBareJid room) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 325 DiscoverInfo info = ServiceDiscoveryManager.getInstanceFor(connection()).discoverInfo(room); 326 return new RoomInfo(info); 327 } 328 329 /** 330 * Returns a collection with the XMPP addresses of the Multi-User Chat services. 331 * 332 * @return a collection with the XMPP addresses of the Multi-User Chat services. 333 * @throws XMPPErrorException 334 * @throws NoResponseException 335 * @throws NotConnectedException 336 * @throws InterruptedException 337 */ 338 public List<DomainBareJid> getXMPPServiceDomains() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 339 ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection()); 340 return sdm.findServices(MUCInitialPresence.NAMESPACE, false, false); 341 } 342 343 /** 344 * Check if the provided domain bare JID provides a MUC service. 345 * 346 * @param domainBareJid the domain bare JID to check. 347 * @return <code>true</code> if the provided JID provides a MUC service, <code>false</code> otherwise. 348 * @throws NoResponseException 349 * @throws XMPPErrorException 350 * @throws NotConnectedException 351 * @throws InterruptedException 352 * @see <a href="http://xmpp.org/extensions/xep-0045.html#disco-service-features">XEP-45 § 6.2 Discovering the Features Supported by a MUC Service</a> 353 * @since 4.2 354 */ 355 public boolean providesMucService(DomainBareJid domainBareJid) throws NoResponseException, 356 XMPPErrorException, NotConnectedException, InterruptedException { 357 return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(domainBareJid, 358 MUCInitialPresence.NAMESPACE); 359 } 360 361 /** 362 * Returns a List of HostedRooms where each HostedRoom has the XMPP address of the room and the room's name. 363 * Once discovered the rooms hosted by a chat service it is possible to discover more detailed room information or 364 * join the room. 365 * 366 * @param serviceName the service that is hosting the rooms to discover. 367 * @return a collection of HostedRooms. 368 * @throws XMPPErrorException 369 * @throws NoResponseException 370 * @throws NotConnectedException 371 * @throws InterruptedException 372 * @throws NotAMucServiceException 373 */ 374 public List<HostedRoom> getHostedRooms(DomainBareJid serviceName) throws NoResponseException, XMPPErrorException, 375 NotConnectedException, InterruptedException, NotAMucServiceException { 376 if (!providesMucService(serviceName)) { 377 throw new NotAMucServiceException(serviceName); 378 } 379 ServiceDiscoveryManager discoManager = ServiceDiscoveryManager.getInstanceFor(connection()); 380 DiscoverItems discoverItems = discoManager.discoverItems(serviceName); 381 List<DiscoverItems.Item> items = discoverItems.getItems(); 382 List<HostedRoom> answer = new ArrayList<HostedRoom>(items.size()); 383 for (DiscoverItems.Item item : items) { 384 answer.add(new HostedRoom(item)); 385 } 386 return answer; 387 } 388 389 /** 390 * Informs the sender of an invitation that the invitee declines the invitation. The rejection will be sent to the 391 * room which in turn will forward the rejection to the inviter. 392 * 393 * @param room the room that sent the original invitation. 394 * @param inviter the inviter of the declined invitation. 395 * @param reason the reason why the invitee is declining the invitation. 396 * @throws NotConnectedException 397 * @throws InterruptedException 398 */ 399 public void decline(EntityBareJid room, EntityBareJid inviter, String reason) throws NotConnectedException, InterruptedException { 400 Message message = new Message(room); 401 402 // Create the MUCUser packet that will include the rejection 403 MUCUser mucUser = new MUCUser(); 404 MUCUser.Decline decline = new MUCUser.Decline(reason, inviter); 405 mucUser.setDecline(decline); 406 // Add the MUCUser packet that includes the rejection 407 message.addExtension(mucUser); 408 409 connection().sendStanza(message); 410 } 411 412 /** 413 * Adds a listener to invitation notifications. The listener will be fired anytime an invitation is received. 414 * 415 * @param listener an invitation listener. 416 */ 417 public void addInvitationListener(InvitationListener listener) { 418 invitationsListeners.add(listener); 419 } 420 421 /** 422 * Removes a listener to invitation notifications. The listener will be fired anytime an invitation is received. 423 * 424 * @param listener an invitation listener. 425 */ 426 public void removeInvitationListener(InvitationListener listener) { 427 invitationsListeners.remove(listener); 428 } 429 430 /** 431 * If automatic join on reconnect is enabled, then the manager will try to auto join MUC rooms after the connection 432 * got re-established. 433 * 434 * @param autoJoin <code>true</code> to enable, <code>false</code> to disable. 435 */ 436 public void setAutoJoinOnReconnect(boolean autoJoin) { 437 autoJoinOnReconnect = autoJoin; 438 } 439 440 /** 441 * Set a callback invoked by this manager when automatic join on reconnect failed. If failedCallback is not 442 * <code>null</code>,then automatic rejoin get also enabled. 443 * 444 * @param failedCallback the callback. 445 */ 446 public void setAutoJoinFailedCallback(AutoJoinFailedCallback failedCallback) { 447 autoJoinFailedCallback = failedCallback; 448 if (failedCallback != null) { 449 setAutoJoinOnReconnect(true); 450 } 451 } 452 453 void addJoinedRoom(EntityBareJid room) { 454 joinedRooms.add(room); 455 } 456 457 void removeJoinedRoom(EntityBareJid room) { 458 joinedRooms.remove(room); 459 } 460}