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}