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}