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