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