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