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}