MamManager.java

/**
 *
 * Copyright © 2017-2018 Florian Schmaus, 2016-2017 Fernando Ramirez
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jivesoftware.smackx.mam;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.WeakHashMap;

import org.jivesoftware.smack.ConnectionCreationListener;
import org.jivesoftware.smack.Manager;
import org.jivesoftware.smack.SmackException.NoResponseException;
import org.jivesoftware.smack.SmackException.NotConnectedException;
import org.jivesoftware.smack.SmackException.NotLoggedInException;
import org.jivesoftware.smack.StanzaCollector;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPConnectionRegistry;
import org.jivesoftware.smack.XMPPException.XMPPErrorException;
import org.jivesoftware.smack.filter.IQReplyFilter;
import org.jivesoftware.smack.packet.IQ;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.util.Objects;
import org.jivesoftware.smack.util.StringUtils;

import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
import org.jivesoftware.smackx.forward.packet.Forwarded;
import org.jivesoftware.smackx.mam.element.MamElements;
import org.jivesoftware.smackx.mam.element.MamElements.MamResultExtension;
import org.jivesoftware.smackx.mam.element.MamFinIQ;
import org.jivesoftware.smackx.mam.element.MamPrefsIQ;
import org.jivesoftware.smackx.mam.element.MamPrefsIQ.DefaultBehavior;
import org.jivesoftware.smackx.mam.element.MamQueryIQ;
import org.jivesoftware.smackx.mam.filter.MamResultFilter;
import org.jivesoftware.smackx.muc.MultiUserChat;
import org.jivesoftware.smackx.rsm.packet.RSMSet;
import org.jivesoftware.smackx.xdata.FormField;
import org.jivesoftware.smackx.xdata.packet.DataForm;

import org.jxmpp.jid.EntityBareJid;
import org.jxmpp.jid.EntityFullJid;
import org.jxmpp.jid.Jid;

/**
 * A Manager for Message Archive Management (MAM, <a href="http://xmpp.org/extensions/xep-0313.html">XEP-0313</a>).
 *
 * <h2>Get an instance of a manager for a message archive</h2>
 *
 * In order to work with {@link MamManager} you need to obtain an instance for a particular archive.
 * To get the instance for the default archive on the user's server, use the {@link #getInstanceFor(XMPPConnection)} method.
 *
 * <pre>
 * {@code
 * XMPPConnection connection = ...
 * MamManager mamManager = MamManager.getInstanceFor(connection);
 * }
 * </pre>
 *
 * If you want to retrieve a manager for a different archive use {@link #getInstanceFor(XMPPConnection, Jid)}, which takes the archive's XMPP address as second argument.
 *
 * <h2>Check if MAM is supported</h2>
 *
 * After you got your manager instance, you probably first want to check if MAM is supported.
 * Simply use {@link #isSupported()} to check if there is a MAM archive available.
 *
 * <pre>
 * {@code
 * boolean isSupported = mamManager.isSupported();
 * }
 * </pre>
 *
 * <h2>Message Archive Preferences</h2>
 *
 * After you have verified that the MAM is supported, you probably want to configure the archive first before using it.
 * One of the most important preference is to enable MAM for your account.
 * Some servers set up new accounts with MAM disabled by default.
 * You can do so by calling {@link #enableMamForAllMessages()}.
 *
 * <h3>Retrieve current preferences</h3>
 *
 * The archive's preferences can be retrieved using {@link #retrieveArchivingPreferences()}.
 *
 * <h3>Update preferences</h3>
 *
 * Use {@link MamPrefsResult#asMamPrefs()} to get a modifiable {@link MamPrefs} instance.
 * After performing the desired changes, use {@link #updateArchivingPreferences(MamPrefs)} to update the preferences.
 *
 * <h2>Query the message archive</h2>
 *
 * Querying a message archive involves a two step process. First you need specify the query's arguments, for example a date range.
 * The query arguments of a particular query are represented by a {@link MamQueryArgs} instance, which can be build using {@link MamQueryArgs.Builder}.
 *
 * After you have build such an instance, use {@link #queryArchive(MamQueryArgs)} to issue the query.
 *
 * <pre>
 * {@code
 * MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
 *                                 .limitResultsToJid(jid)
 *                                 .setResultPageSizeTo(10)
 *                                 .queryLastPage()
 *                                 .build();
 * MamQuery mamQuery = mamManager.queryArchive(mamQueryArgs);
 * }
 * </pre>
 *
 * On success {@link #queryArchive(MamQueryArgs)} returns a {@link MamQuery} instance.
 * The instance will hold one page of the queries result set.
 * Use {@link MamQuery#getMessages()} to retrieve the messages of the archive belonging to the page.
 *
 * You can get the whole page including all metadata using {@link MamQuery#getPage()}.
 *
 * <h2>Paging through the results</h2>
 *
 * Because the matching result set could be potentially very big, a MAM service will probably not return all matching messages.
 * Instead the results are possibly send in multiple pages.
 * To check if the result was complete or if there are further pages, use {@link MamQuery#isComplete()}.
 * If this method returns {@code false}, then you may want to page through the archive.
 *
 * {@link MamQuery} provides convince methods to do so: {@link MamQuery#pageNext(int)} and {@link MamQuery#pagePrevious(int)}.
 *
 * <pre>
 * {@code
 * MamQuery nextPageMamQuery = mamQuery.pageNext(10);
 * }
 * </pre>
 *
 * <h2>Get the supported form fields</h2>
 *
 * You can use {@link #retrieveFormFields()} to retrieve a list of the supported additional form fields by this archive.
 * Those fields can be used for further restrict a query.
 *
 *
 * @see <a href="http://xmpp.org/extensions/xep-0313.html">XEP-0313: Message
 *      Archive Management</a>
 * @author Florian Schmaus
 * @author Fernando Ramirez
 *
 */
public final class MamManager extends Manager {

    static {
        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
            @Override
            public void connectionCreated(XMPPConnection connection) {
                getInstanceFor(connection);
            }
        });
    }

    private static final String FORM_FIELD_WITH = "with";
    private static final String FORM_FIELD_START = "start";
    private static final String FORM_FIELD_END = "end";

    private static final Map<XMPPConnection, Map<Jid, MamManager>> INSTANCES = new WeakHashMap<>();

    /**
     * Get a MamManager for the MAM archive of the local entity (the "user") of the given connection.
     *
     * @param connection the XMPP connection to get the archive for.
     * @return the instance of MamManager.
     */
    public static MamManager getInstanceFor(XMPPConnection connection) {
        return getInstanceFor(connection, (Jid) null);
    }

    /**
     * Get a MamManager for the MAM archive of the given {@code MultiUserChat}. Note that not all MUCs support MAM,
     * hence it is recommended to use {@link #isSupported()} to check if MAM is supported by the MUC.
     *
     * @param multiUserChat the MultiUserChat to retrieve the MamManager for.
     * @return the MamManager for the given MultiUserChat.
     * @since 4.3.0
     */
    public static MamManager getInstanceFor(MultiUserChat multiUserChat) {
        XMPPConnection connection = multiUserChat.getXmppConnection();
        Jid archiveAddress = multiUserChat.getRoom();
        return getInstanceFor(connection, archiveAddress);
    }

    public static synchronized MamManager getInstanceFor(XMPPConnection connection, Jid archiveAddress) {
        Map<Jid, MamManager> managers = INSTANCES.get(connection);
        if (managers == null) {
            managers = new HashMap<>();
            INSTANCES.put(connection, managers);
        }
        MamManager mamManager = managers.get(archiveAddress);
        if (mamManager == null) {
            mamManager = new MamManager(connection, archiveAddress);
            managers.put(archiveAddress, mamManager);
        }
        return mamManager;
    }

    private final Jid archiveAddress;

    private final ServiceDiscoveryManager serviceDiscoveryManager;

    private MamManager(XMPPConnection connection, Jid archiveAddress) {
        super(connection);
        this.archiveAddress = archiveAddress;
        serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(connection);
    }

    /**
     * The the XMPP address of this MAM archive. Note that this method may return {@code null} if this MamManager
     * handles the local entity's archive and if the connection has never been authenticated at least once.
     *
     * @return the XMPP address of this MAM archive or {@code null}.
     * @since 4.3.0
     */
    public Jid getArchiveAddress() {
        if (archiveAddress == null) {
            EntityFullJid localJid = connection().getUser();
            if (localJid == null) {
                return null;
            }
            return localJid.asBareJid();
        }
        return archiveAddress;
    }

    public static final class MamQueryArgs {
        private final String node;

        private final Map<String, FormField> formFields;

        private final Integer maxResults;

        private final String afterUid;

        private final String beforeUid;

        private MamQueryArgs(Builder builder) {
            node = builder.node;
            formFields = builder.formFields;
            if (builder.maxResults > 0) {
                maxResults = builder.maxResults;
            } else {
                maxResults = null;
            }
            afterUid = builder.afterUid;
            beforeUid = builder.beforeUid;
        }

        private DataForm dataForm;

        DataForm getDataForm() {
            if (dataForm != null) {
                return dataForm;
            }
            dataForm = getNewMamForm();
            dataForm.addFields(formFields.values());
            return dataForm;
        }

        void maybeAddRsmSet(MamQueryIQ mamQueryIQ) {
            if (maxResults == null && afterUid == null && beforeUid == null) {
                return;
            }

            int max;
            if (maxResults != null) {
                max = maxResults;
            } else {
                max = -1;
            }

            RSMSet rsmSet = new RSMSet(afterUid, beforeUid, -1, -1, null, max, null, -1);
            mamQueryIQ.addExtension(rsmSet);
        }

        public static Builder builder() {
            return new Builder();
        }

        public static final class Builder {
            private String node;

            private final Map<String, FormField> formFields = new HashMap<>(8);

            private int maxResults = -1;

            private String afterUid;

            private String beforeUid;

            public Builder queryNode(String node) {
                if (node == null) {
                    return this;
                }

                this.node = node;

                return this;
            }

            public Builder limitResultsToJid(Jid withJid) {
                if (withJid == null) {
                    return this;
                }

                FormField formField = getWithFormField(withJid);
                formFields.put(formField.getVariable(), formField);

                return this;
            }

            public Builder limitResultsSince(Date start) {
                if (start == null) {
                    return this;
                }

                FormField formField = new FormField(FORM_FIELD_START);
                formField.addValue(start);
                formFields.put(formField.getVariable(), formField);

                FormField endFormField = formFields.get(FORM_FIELD_END);
                if (endFormField != null) {
                    Date end;
                    try {
                        end = endFormField.getFirstValueAsDate();
                    }
                    catch (ParseException e) {
                        throw new IllegalStateException(e);
                    }
                    if (end.getTime() <= start.getTime()) {
                        throw new IllegalArgumentException("Given start date (" + start
                                        + ") is after the existing end date (" + end + ')');
                    }
                }

                return this;
            }

            public Builder limitResultsBefore(Date end) {
                if (end == null) {
                    return this;
                }

                FormField formField = new FormField(FORM_FIELD_END);
                formField.addValue(end);
                formFields.put(formField.getVariable(), formField);

                FormField startFormField = formFields.get(FORM_FIELD_START);
                if (startFormField != null) {
                    Date start;
                    try {
                        start = startFormField.getFirstValueAsDate();
                    } catch (ParseException e) {
                        throw new IllegalStateException(e);
                    }
                    if (end.getTime() <= start.getTime()) {
                        throw new IllegalArgumentException("Given end date (" + end
                                        + ") is before the existing start date (" + start + ')');
                    }
                }

                return this;
            }

            public Builder setResultPageSize(Integer max) {
                if (max == null) {
                    maxResults = -1;
                    return this;
                }
                return setResultPageSizeTo(max.intValue());
            }

            public Builder setResultPageSizeTo(int max) {
                if (max < 0) {
                    throw new IllegalArgumentException();
                }
                this.maxResults = max;
                return this;
            }

            /**
             * Only return the count of messages the query yields, not the actual messages. Note that not all services
             * return a correct count, some return an approximate count.
             *
             * @return an reference to this builder.
             * @see <a href="https://xmpp.org/extensions/xep-0059.html#count">XEP-0059 § 2.7</a>
             */
            public Builder onlyReturnMessageCount() {
                return setResultPageSizeTo(0);
            }

            public Builder withAdditionalFormField(FormField formField) {
                formFields.put(formField.getVariable(), formField);
                return this;
            }

            public Builder withAdditionalFormFields(List<FormField> additionalFields) {
                for (FormField formField : additionalFields) {
                    withAdditionalFormField(formField);
                }
                return this;
            }

            public Builder afterUid(String afterUid) {
                this.afterUid = StringUtils.requireNullOrNotEmpty(afterUid, "afterUid must not be empty");
                return this;
            }

            /**
             * Specifies a message UID as 'before' anchor for the query. Note that unlike {@link #afterUid(String)} this
             * method also accepts the empty String to query the last page of an archive (c.f. XEP-0059 § 2.5).
             *
             * @param beforeUid a message UID acting as 'before' query anchor.
             * @return an instance to this builder.
             */
            public Builder beforeUid(String beforeUid) {
                // We don't perform any argument validation, since every possible argument (null, empty string,
                // non-empty string) is valid.
                this.beforeUid = beforeUid;
                return this;
            }

            /**
             * Query from the last, i.e. most recent, page of the archive. This will return the very last page of the
             * archive holding the most recent matching messages. You usually would page backwards from there on.
             *
             * @return a reference to this builder.
             * @see <a href="https://xmpp.org/extensions/xep-0059.html#last">XEP-0059 § 2.5. Requesting the Last Page in
             *      a Result Set</a>
             */
            public Builder queryLastPage() {
                return beforeUid("");
            }

            public MamQueryArgs build() {
                return new MamQueryArgs(this);
            }
        }
    }

    /**
     * Query archive with a maximum amount of results.
     *
     * @param max
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(Integer max) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, max, null, null, null, null);
    }

    /**
     * Query archive with a JID (only messages from/to the JID).
     *
     * @param withJid
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(Jid withJid) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, null, null, null, withJid, null);
    }

    /**
     * Query archive filtering by start and/or end date. If start == null, the
     * value of 'start' will be equal to the date/time of the earliest message
     * stored in the archive. If end == null, the value of 'end' will be equal
     * to the date/time of the most recent message stored in the archive.
     *
     * @param start
     * @param end
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(Date start, Date end) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, null, start, end, null, null);
    }

    /**
     * Query Archive adding filters with additional fields.
     *
     * @param additionalFields
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(List<FormField> additionalFields) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, null, null, null, null, additionalFields);
    }

    /**
     * Query archive filtering by start date. The value of 'end' will be equal
     * to the date/time of the most recent message stored in the archive.
     *
     * @param start
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchiveWithStartDate(Date start) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, null, start, null, null, null);
    }

    /**
     * Query archive filtering by end date. The value of 'start' will be equal
     * to the date/time of the earliest message stored in the archive.
     *
     * @param end
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchiveWithEndDate(Date end) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        return queryArchive(null, null, null, end, null, null);
    }


    /**
     * Query archive applying filters: max count, start date, end date, from/to
     * JID and with additional fields.
     *
     * @param max
     * @param start
     * @param end
     * @param withJid
     * @param additionalFields
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(Integer max, Date start, Date end, Jid withJid, List<FormField> additionalFields)
            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException,
            NotLoggedInException {
      return queryArchive(null, max, start, end, withJid, additionalFields);
    }


    /**
     * Query an message archive like a MUC archive or a PubSub node archive, addressed by an archiveAddress, applying
     * filters: max count, start date, end date, from/to JID and with additional fields. When archiveAddress is null the
     * default, the server will be requested.
     *
     * @param node The PubSub node name, can be null
     * @param max
     * @param start
     * @param end
     * @param withJid
     * @param additionalFields
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult queryArchive(String node, Integer max, Date start, Date end, Jid withJid,
                    List<FormField> additionalFields)
            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException,
            NotLoggedInException {
        MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
                        .queryNode(node)
                        .setResultPageSize(max)
                        .limitResultsSince(start)
                        .limitResultsBefore(end)
                        .limitResultsToJid(withJid)
                        .withAdditionalFormFields(additionalFields)
                        .build();

        MamQuery mamQuery = queryArchive(mamQueryArgs);
        return new MamQueryResult(mamQuery);
    }

    public MamQuery queryArchive(MamQueryArgs mamQueryArgs) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, NotLoggedInException, InterruptedException {
        String queryId = UUID.randomUUID().toString();
        String node = mamQueryArgs.node;
        DataForm dataForm = mamQueryArgs.getDataForm();

        MamQueryIQ mamQueryIQ = new MamQueryIQ(queryId, node, dataForm);
        mamQueryIQ.setType(IQ.Type.set);
        mamQueryIQ.setTo(archiveAddress);

        mamQueryArgs.maybeAddRsmSet(mamQueryIQ);

        return queryArchive(mamQueryIQ);
    }

    private static FormField getWithFormField(Jid withJid) {
        FormField formField = new FormField(FORM_FIELD_WITH);
        formField.addValue(withJid.toString());
        return formField;
    }

    private static void addWithJid(Jid withJid, DataForm dataForm) {
        if (withJid == null) {
            return;
        }
        FormField formField = getWithFormField(withJid);
        dataForm.addField(formField);
    }

    /**
     * Returns a page of the archive.
     *
     * @param dataForm
     * @param rsmSet
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult page(DataForm dataForm, RSMSet rsmSet) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, InterruptedException, NotLoggedInException {
        return page(null, dataForm, rsmSet);
    }

    /**
     * Returns a page of the archive. This is a low-level method, you possibly do not want to use it directly unless you
     * know what you are doing.
     *
     * @param node The PubSub node name, can be null
     * @param dataForm
     * @param rsmSet
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult page(String node, DataForm dataForm, RSMSet rsmSet)
                    throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        MamQueryIQ mamQueryIQ = new MamQueryIQ(UUID.randomUUID().toString(), node, dataForm);
        mamQueryIQ.setType(IQ.Type.set);
        mamQueryIQ.setTo(archiveAddress);
        mamQueryIQ.addExtension(rsmSet);
        MamQuery mamQuery = queryArchive(mamQueryIQ);
        return new MamQueryResult(mamQuery);
    }

    /**
     * Returns the next page of the archive.
     *
     * @param mamQueryResult
     *            is the previous query result
     * @param count
     *            is the amount of messages that a page contains
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link MamQuery#pageNext(int)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult pageNext(MamQueryResult mamQueryResult, int count) throws NoResponseException,
            XMPPErrorException, NotConnectedException, InterruptedException, NotLoggedInException {
        RSMSet previousResultRsmSet = mamQueryResult.mamFin.getRSMSet();
        RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getLast(), RSMSet.PageDirection.after);
        return page(mamQueryResult, requestRsmSet);
    }

    /**
     * Returns the previous page of the archive.
     *
     * @param mamQueryResult
     *            is the previous query result
     * @param count
     *            is the amount of messages that a page contains
     * @return the MAM query result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link MamQuery#pagePrevious(int)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult pagePrevious(MamQueryResult mamQueryResult, int count) throws NoResponseException,
            XMPPErrorException, NotConnectedException, InterruptedException, NotLoggedInException {
        RSMSet previousResultRsmSet = mamQueryResult.mamFin.getRSMSet();
        RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getFirst(), RSMSet.PageDirection.before);
        return page(mamQueryResult, requestRsmSet);
    }

    private MamQueryResult page(MamQueryResult mamQueryResult, RSMSet requestRsmSet) throws NoResponseException,
                    XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException {
        ensureMamQueryResultMatchesThisManager(mamQueryResult);

        return page(mamQueryResult.node, mamQueryResult.form, requestRsmSet);
    }

    /**
     * Obtain page before the first message saved (specific chat).
     * <p>
     * Note that the messageUid is the XEP-0313 UID and <b>not</b> the stanza ID of the message.
     * </p>
     *
     * @param chatJid
     * @param messageUid the UID of the message of which messages before should be received.
     * @param max
     * @return the MAM query result
     * @throws XMPPErrorException
     * @throws NotLoggedInException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NoResponseException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult pageBefore(Jid chatJid, String messageUid, int max) throws XMPPErrorException,
            NotLoggedInException, NotConnectedException, InterruptedException, NoResponseException {
        RSMSet rsmSet = new RSMSet(null, messageUid, -1, -1, null, max, null, -1);
        DataForm dataForm = getNewMamForm();
        addWithJid(chatJid, dataForm);
        return page(null, dataForm, rsmSet);
    }

    /**
     * Obtain page after the last message saved (specific chat).
     * <p>
     * Note that the messageUid is the XEP-0313 UID and <b>not</b> the stanza ID of the message.
     * </p>
     *
     * @param chatJid
     * @param messageUid the UID of the message of which messages after should be received.
     * @param max
     * @return the MAM query result
     * @throws XMPPErrorException
     * @throws NotLoggedInException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NoResponseException
     * @deprecated use {@link #queryArchive(MamQueryArgs)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult pageAfter(Jid chatJid, String messageUid, int max) throws XMPPErrorException,
            NotLoggedInException, NotConnectedException, InterruptedException, NoResponseException {
        RSMSet rsmSet = new RSMSet(messageUid, null, -1, -1, null, max, null, -1);
        DataForm dataForm = getNewMamForm();
        addWithJid(chatJid, dataForm);
        return page(null, dataForm, rsmSet);
    }

    /**
     * Obtain the most recent page of a chat.
     *
     * @param chatJid
     * @param max
     * @return the MAM query result
     * @throws XMPPErrorException
     * @throws NotLoggedInException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NoResponseException
     * @deprecated use {@link #queryMostRecentPage(Jid, int)} instead.
     */
    @Deprecated
    // TODO Remove in Smack 4.4
    public MamQueryResult mostRecentPage(Jid chatJid, int max) throws XMPPErrorException, NotLoggedInException,
            NotConnectedException, InterruptedException, NoResponseException {
        return pageBefore(chatJid, "", max);
    }

    public MamQuery queryMostRecentPage(Jid jid, int max) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, NotLoggedInException, InterruptedException {
        MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
                        // Produces an empty <before/> element for XEP-0059 § 2.5
                        .queryLastPage()
                        .limitResultsToJid(jid)
                        .setResultPageSize(max)
                        .build();
        return queryArchive(mamQueryArgs);
    }

    /**
     * Get the form fields supported by the server.
     *
     * @return the list of form fields.
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     */
    public List<FormField> retrieveFormFields() throws NoResponseException, XMPPErrorException, NotConnectedException,
                    InterruptedException, NotLoggedInException {
        return retrieveFormFields(null);
    }

    /**
     * Get the form fields supported by the server.
     *
     * @param node The PubSub node name, can be null
     * @return the list of form fields.
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     */
    public List<FormField> retrieveFormFields(String node)
                    throws NoResponseException, XMPPErrorException, NotConnectedException,
            InterruptedException, NotLoggedInException {
        String queryId = UUID.randomUUID().toString();
        MamQueryIQ mamQueryIq = new MamQueryIQ(queryId, node, null);
        mamQueryIq.setTo(archiveAddress);

        MamQueryIQ mamResponseQueryIq = connection().createStanzaCollectorAndSend(mamQueryIq).nextResultOrThrow();

        return mamResponseQueryIq.getDataForm().getFields();
    }

    private MamQuery queryArchive(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, InterruptedException, NotLoggedInException {
        MamQueryPage mamQueryPage = queryArchivePage(mamQueryIq);
        return new MamQuery(mamQueryPage, mamQueryIq.getNode(), DataForm.from(mamQueryIq));
    }

    private MamQueryPage queryArchivePage(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, InterruptedException, NotLoggedInException {
        final XMPPConnection connection = getAuthenticatedConnectionOrThrow();
        MamFinIQ mamFinIQ;

        StanzaCollector mamFinIQCollector = connection.createStanzaCollector(new IQReplyFilter(mamQueryIq, connection));

        StanzaCollector.Configuration resultCollectorConfiguration = StanzaCollector.newConfiguration()
                .setStanzaFilter(new MamResultFilter(mamQueryIq)).setCollectorToReset(mamFinIQCollector);
        StanzaCollector resultCollector = connection.createStanzaCollector(resultCollectorConfiguration);

        try {
            connection.sendStanza(mamQueryIq);
            mamFinIQ = mamFinIQCollector.nextResultOrThrow();
        } finally {
            mamFinIQCollector.cancel();
            resultCollector.cancel();
        }

        return new MamQueryPage(resultCollector, mamFinIQ);
    }

    /**
     * MAM query result class.
     *
     */
    @Deprecated
    public static final class MamQueryResult {
        public final List<Forwarded> forwardedMessages;
        public final MamFinIQ mamFin;
        private final String node;
        private final DataForm form;

        private MamQueryResult(MamQuery mamQuery) {
            this(mamQuery.mamQueryPage.forwardedMessages, mamQuery.mamQueryPage.mamFin, mamQuery.node, mamQuery.form);
        }

        private MamQueryResult(List<Forwarded> forwardedMessages, MamFinIQ mamFin, String node, DataForm form) {
            this.forwardedMessages = forwardedMessages;
            this.mamFin = mamFin;
            this.node = node;
            this.form = form;
        }
    }

    public final class MamQuery {
        private final String node;
        private final DataForm form;

        private MamQueryPage mamQueryPage;

        private MamQuery(MamQueryPage mamQueryPage, String node, DataForm form) {
            this.node = node;
            this.form = form;

            this.mamQueryPage = mamQueryPage;
        }

        public boolean isComplete() {
            return mamQueryPage.getMamFinIq().isComplete();
        }

        public List<Message> getMessages() {
            return mamQueryPage.messages;
        }

        public List<MamResultExtension> getMamResultExtensions() {
            return mamQueryPage.mamResultExtensions;
        }

        private List<Message> page(RSMSet requestRsmSet) throws NoResponseException, XMPPErrorException,
                        NotConnectedException, NotLoggedInException, InterruptedException {
            MamQueryIQ mamQueryIQ = new MamQueryIQ(UUID.randomUUID().toString(), node, form);
            mamQueryIQ.setType(IQ.Type.set);
            mamQueryIQ.setTo(archiveAddress);
            mamQueryIQ.addExtension(requestRsmSet);

            mamQueryPage = queryArchivePage(mamQueryIQ);

            return mamQueryPage.messages;
        }

        private RSMSet getPreviousRsmSet() {
            return mamQueryPage.getMamFinIq().getRSMSet();
        }

        public List<Message> pageNext(int count) throws NoResponseException, XMPPErrorException, NotConnectedException,
                        NotLoggedInException, InterruptedException {
            RSMSet previousResultRsmSet = getPreviousRsmSet();
            RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getLast(), RSMSet.PageDirection.after);
            return page(requestRsmSet);
        }

        public List<Message> pagePrevious(int count) throws NoResponseException, XMPPErrorException,
                        NotConnectedException, NotLoggedInException, InterruptedException {
            RSMSet previousResultRsmSet = getPreviousRsmSet();
            RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getFirst(), RSMSet.PageDirection.before);
            return page(requestRsmSet);
        }

        public int getMessageCount() {
            return getMessages().size();
        }

        public MamQueryPage getPage() {
            return mamQueryPage;
        }
    }

    public static final class MamQueryPage {
        private final MamFinIQ mamFin;
        private final List<Message> mamResultCarrierMessages;
        private final List<MamResultExtension> mamResultExtensions;
        private final List<Forwarded> forwardedMessages;
        private final List<Message> messages;

        private MamQueryPage(StanzaCollector stanzaCollector, MamFinIQ mamFin) {
            this.mamFin = mamFin;

            List<Stanza> mamResultCarrierStanzas = stanzaCollector.getCollectedStanzasAfterCancelled();

            List<Message> mamResultCarrierMessages = new ArrayList<>(mamResultCarrierStanzas.size());
            List<MamResultExtension> mamResultExtensions = new ArrayList<>(mamResultCarrierStanzas.size());
            List<Forwarded> forwardedMessages = new ArrayList<>(mamResultCarrierStanzas.size());

            for (Stanza mamResultStanza : mamResultCarrierStanzas) {
                Message resultMessage = (Message) mamResultStanza;

                mamResultCarrierMessages.add(resultMessage);

                MamElements.MamResultExtension mamResultExtension = MamElements.MamResultExtension.from(resultMessage);
                mamResultExtensions.add(mamResultExtension);

                forwardedMessages.add(mamResultExtension.getForwarded());
            }

            this.mamResultCarrierMessages = Collections.unmodifiableList(mamResultCarrierMessages);
            this.mamResultExtensions = Collections.unmodifiableList(mamResultExtensions);
            this.forwardedMessages = Collections.unmodifiableList(forwardedMessages);
            this.messages = Collections.unmodifiableList(Forwarded.extractMessagesFrom(forwardedMessages));
        }

        public List<Message> getMessages() {
            return messages;
        }

        public List<Forwarded> getForwarded() {
            return forwardedMessages;
        }

        public List<MamResultExtension> getMamResultExtensions() {
            return mamResultExtensions;
        }

        public List<Message> getMamResultCarrierMessages() {
            return mamResultCarrierMessages;
        }

        public MamFinIQ getMamFinIq() {
            return mamFin;
        }
    }

    private void ensureMamQueryResultMatchesThisManager(MamQueryResult mamQueryResult) {
        EntityFullJid localAddress = connection().getUser();
        EntityBareJid localBareAddress = null;
        if (localAddress != null) {
            localBareAddress = localAddress.asEntityBareJid();
        }
        boolean isLocalUserArchive = archiveAddress == null || archiveAddress.equals(localBareAddress);

        Jid finIqFrom = mamQueryResult.mamFin.getFrom();

        if (finIqFrom != null) {
            if (finIqFrom.equals(archiveAddress) || (isLocalUserArchive && finIqFrom.equals(localBareAddress))) {
                return;
            }
            throw new IllegalArgumentException("The given MamQueryResult is from the MAM archive '" + finIqFrom
                            + "' whereas this MamManager is responsible for '" + archiveAddress + '\'');
        }
        else if (!isLocalUserArchive) {
            throw new IllegalArgumentException(
                            "The given MamQueryResult is from the local entity (user) MAM archive, whereas this MamManager is responsible for '"
                                            + archiveAddress + '\'');
        }
    }

    /**
     * Check if this MamManager's archive address supports MAM.
     *
     * @return true if MAM is supported, <code>false</code>otherwise.
     *
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @since 4.2.1
     * @see <a href="https://xmpp.org/extensions/xep-0313.html#support">XEP-0313 § 7. Determining support</a>
     */
    public boolean isSupported() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
        // Note that this may return 'null' but SDM's supportsFeature() does the right thing™ then.
        Jid archiveAddress = getArchiveAddress();
        return serviceDiscoveryManager.supportsFeature(archiveAddress, MamElements.NAMESPACE);
    }

    private static DataForm getNewMamForm() {
        FormField field = new FormField(FormField.FORM_TYPE);
        field.setType(FormField.Type.hidden);
        field.addValue(MamElements.NAMESPACE);
        DataForm form = new DataForm(DataForm.Type.submit);
        form.addField(field);
        return form;
    }

    /**
     * Lookup the archive's message ID of the latest message in the archive. Returns {@code null} if the archive is
     * empty.
     *
     * @return the ID of the lastest message or {@code null}.
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws NotLoggedInException
     * @throws InterruptedException
     * @since 4.3.0
     */
    public String getMessageUidOfLatestMessage() throws NoResponseException, XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException {
        MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
                .setResultPageSize(1)
                .queryLastPage()
                .build();

        MamQuery mamQuery = queryArchive(mamQueryArgs);
        if (mamQuery.getMessages().isEmpty()) {
            return null;
        }

        return mamQuery.getMamResultExtensions().get(0).getId();
    }

    /**
     * Get the preferences stored in the server.
     *
     * @return the MAM preferences result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     */
    public MamPrefsResult retrieveArchivingPreferences() throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        MamPrefsIQ mamPrefIQ = new MamPrefsIQ();
        return queryMamPrefs(mamPrefIQ);
    }

    /**
     * Update the preferences in the server.
     *
     * @param alwaysJids
     *            is the list of JIDs that should always have messages to/from
     *            archived in the user's store
     * @param neverJids
     *            is the list of JIDs that should never have messages to/from
     *            archived in the user's store
     * @param defaultBehavior
     *            can be "roster", "always", "never" (see XEP-0313)
     * @return the MAM preferences result
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @deprecated use {@link #updateArchivingPreferences(MamPrefs)} instead.
     */
    @Deprecated
    public MamPrefsResult updateArchivingPreferences(List<Jid> alwaysJids, List<Jid> neverJids, DefaultBehavior defaultBehavior)
            throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException,
            NotLoggedInException {
        Objects.requireNonNull(defaultBehavior, "Default behavior must be set");
        MamPrefsIQ mamPrefIQ = new MamPrefsIQ(alwaysJids, neverJids, defaultBehavior);
        return queryMamPrefs(mamPrefIQ);
    }

    /**
     * Update the preferences in the server.
     *
     * @param mamPrefs
     * @return the currently active preferences after the operation.
     * @throws NoResponseException
     * @throws XMPPErrorException
     * @throws NotConnectedException
     * @throws InterruptedException
     * @throws NotLoggedInException
     * @since 4.3.0
     */
    public MamPrefsResult updateArchivingPreferences(MamPrefs mamPrefs) throws NoResponseException, XMPPErrorException,
                    NotConnectedException, InterruptedException, NotLoggedInException {
        MamPrefsIQ mamPrefIQ = mamPrefs.constructMamPrefsIq();
        return queryMamPrefs(mamPrefIQ);
    }

    public MamPrefsResult enableMamForAllMessages() throws NoResponseException, XMPPErrorException,
                    NotConnectedException, NotLoggedInException, InterruptedException {
        return setDefaultBehavior(DefaultBehavior.always);
    }

    public MamPrefsResult enableMamForRosterMessages() throws NoResponseException, XMPPErrorException,
                    NotConnectedException, NotLoggedInException, InterruptedException {
        return setDefaultBehavior(DefaultBehavior.roster);
    }

    public MamPrefsResult setDefaultBehavior(DefaultBehavior desiredDefaultBehavior) throws NoResponseException,
                    XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException {
        MamPrefsResult mamPrefsResult = retrieveArchivingPreferences();
        if (mamPrefsResult.mamPrefs.getDefault() == desiredDefaultBehavior) {
            return mamPrefsResult;
        }

        MamPrefs mamPrefs = mamPrefsResult.asMamPrefs();
        mamPrefs.setDefaultBehavior(desiredDefaultBehavior);
        return updateArchivingPreferences(mamPrefs);
    }

    /**
     * MAM preferences result class.
     *
     */
    public static final class MamPrefsResult {
        public final MamPrefsIQ mamPrefs;
        public final DataForm form;

        private MamPrefsResult(MamPrefsIQ mamPrefs, DataForm form) {
            this.mamPrefs = mamPrefs;
            this.form = form;
        }

        public MamPrefs asMamPrefs() {
            return new MamPrefs(this);
        }
    }

    public static final class MamPrefs {
        private final List<Jid> alwaysJids;
        private final List<Jid> neverJids;
        private DefaultBehavior defaultBehavior;

        private MamPrefs(MamPrefsResult mamPrefsResult) {
            MamPrefsIQ mamPrefsIq = mamPrefsResult.mamPrefs;
            this.alwaysJids = new ArrayList<>(mamPrefsIq.getAlwaysJids());
            this.neverJids = new ArrayList<>(mamPrefsIq.getNeverJids());
            this.defaultBehavior = mamPrefsIq.getDefault();
        }

        public void setDefaultBehavior(DefaultBehavior defaultBehavior) {
            this.defaultBehavior = Objects.requireNonNull(defaultBehavior, "defaultBehavior must not be null");
        }

        public DefaultBehavior getDefaultBehavior() {
            return defaultBehavior;
        }

        public List<Jid> getAlwaysJids() {
            return alwaysJids;
        }

        public List<Jid> getNeverJids() {
            return neverJids;
        }

        private MamPrefsIQ constructMamPrefsIq() {
            return new MamPrefsIQ(alwaysJids, neverJids, defaultBehavior);
        }
    }

    private MamPrefsResult queryMamPrefs(MamPrefsIQ mamPrefsIQ) throws NoResponseException, XMPPErrorException,
            NotConnectedException, InterruptedException, NotLoggedInException {
        final XMPPConnection connection = getAuthenticatedConnectionOrThrow();

        MamPrefsIQ mamPrefsResultIQ = connection.createStanzaCollectorAndSend(mamPrefsIQ).nextResultOrThrow();

        return new MamPrefsResult(mamPrefsResultIQ, DataForm.from(mamPrefsIQ));
    }

}