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