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