001/**
002 *
003 * Copyright © 2017-2024 Florian Schmaus, 2016-2017 Fernando Ramirez
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.mam;
018
019import java.text.ParseException;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.Date;
023import java.util.HashMap;
024import java.util.LinkedHashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.WeakHashMap;
028
029import org.jivesoftware.smack.ConnectionCreationListener;
030import org.jivesoftware.smack.Manager;
031import org.jivesoftware.smack.SmackException;
032import org.jivesoftware.smack.SmackException.NoResponseException;
033import org.jivesoftware.smack.SmackException.NotConnectedException;
034import org.jivesoftware.smack.SmackException.NotLoggedInException;
035import org.jivesoftware.smack.StanzaCollector;
036import org.jivesoftware.smack.XMPPConnection;
037import org.jivesoftware.smack.XMPPConnectionRegistry;
038import org.jivesoftware.smack.XMPPException;
039import org.jivesoftware.smack.XMPPException.XMPPErrorException;
040import org.jivesoftware.smack.filter.IQReplyFilter;
041import org.jivesoftware.smack.packet.IQ;
042import org.jivesoftware.smack.packet.Message;
043import org.jivesoftware.smack.packet.Stanza;
044import org.jivesoftware.smack.util.Objects;
045import org.jivesoftware.smack.util.StringUtils;
046
047import org.jivesoftware.smackx.commands.AdHocCommand;
048import org.jivesoftware.smackx.commands.AdHocCommandManager;
049import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
050import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
051import org.jivesoftware.smackx.disco.packet.DiscoverItems;
052import org.jivesoftware.smackx.forward.packet.Forwarded;
053import org.jivesoftware.smackx.mam.element.MamElementFactory;
054import org.jivesoftware.smackx.mam.element.MamElements;
055import org.jivesoftware.smackx.mam.element.MamElements.MamResultExtension;
056import org.jivesoftware.smackx.mam.element.MamFinIQ;
057import org.jivesoftware.smackx.mam.element.MamPrefsIQ;
058import org.jivesoftware.smackx.mam.element.MamPrefsIQ.DefaultBehavior;
059import org.jivesoftware.smackx.mam.element.MamQueryIQ;
060import org.jivesoftware.smackx.mam.element.MamVersion;
061import org.jivesoftware.smackx.mam.filter.MamResultFilter;
062import org.jivesoftware.smackx.muc.MultiUserChat;
063import org.jivesoftware.smackx.rsm.packet.RSMSet;
064import org.jivesoftware.smackx.xdata.FormField;
065import org.jivesoftware.smackx.xdata.packet.DataForm;
066
067import org.jxmpp.jid.EntityFullJid;
068import org.jxmpp.jid.Jid;
069
070/**
071 * A Manager for Message Archive Management (MAM, <a href="http://xmpp.org/extensions/xep-0313.html">XEP-0313</a>).
072 *
073 * <h2>Get an instance of a manager for a message archive</h2>
074 *
075 * In order to work with {@link MamManager} you need to obtain an instance for a particular archive.
076 * To get the instance for the default archive on the user's server, use the {@link #getInstanceFor(XMPPConnection)} method.
077 *
078 * <pre>
079 * {@code
080 * XMPPConnection connection = ...
081 * MamManager mamManager = MamManager.getInstanceFor(connection);
082 * }
083 * </pre>
084 *
085 * 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.
086 *
087 * <h2>Check if MAM is supported</h2>
088 *
089 * After you got your manager instance, you probably first want to check if MAM is supported.
090 * Simply use {@link #isSupported()} to check if there is a MAM archive available.
091 *
092 * <pre>
093 * {@code
094 * boolean isSupported = mamManager.isSupported();
095 * }
096 * </pre>
097 *
098 * <h2>Message Archive Preferences</h2>
099 *
100 * After you have verified that the MAM is supported, you probably want to configure the archive first before using it.
101 * One of the most important preference is to enable MAM for your account.
102 * Some servers set up new accounts with MAM disabled by default.
103 * You can do so by calling {@link #enableMamForAllMessages()}.
104 *
105 * <h3>Retrieve current preferences</h3>
106 *
107 * The archive's preferences can be retrieved using {@link #retrieveArchivingPreferences()}.
108 *
109 * <h3>Update preferences</h3>
110 *
111 * Use {@link MamPrefsResult#asMamPrefs()} to get a modifiable {@link MamPrefs} instance.
112 * After performing the desired changes, use {@link #updateArchivingPreferences(MamPrefs)} to update the preferences.
113 *
114 * <h2>Query the message archive</h2>
115 *
116 * Querying a message archive involves a two step process. First you need specify the query's arguments, for example a date range.
117 * The query arguments of a particular query are represented by a {@link MamQueryArgs} instance, which can be build using {@link MamQueryArgs.Builder}.
118 *
119 * After you have build such an instance, use {@link #queryArchive(MamQueryArgs)} to issue the query.
120 *
121 * <pre>
122 * {@code
123 * MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
124 *                                 .limitResultsToJid(jid)
125 *                                 .setResultPageSizeTo(10)
126 *                                 .queryLastPage()
127 *                                 .build();
128 * MamQuery mamQuery = mamManager.queryArchive(mamQueryArgs);
129 * }
130 * </pre>
131 *
132 * On success {@link #queryArchive(MamQueryArgs)} returns a {@link MamQuery} instance.
133 * The instance will hold one page of the queries result set.
134 * Use {@link MamQuery#getMessages()} to retrieve the messages of the archive belonging to the page.
135 *
136 * You can get the whole page including all metadata using {@link MamQuery#getPage()}.
137 *
138 * <h2>Paging through the results</h2>
139 *
140 * Because the matching result set could be potentially very big, a MAM service will probably not return all matching messages.
141 * Instead the results are possibly send in multiple pages.
142 * To check if the result was complete or if there are further pages, use {@link MamQuery#isComplete()}.
143 * If this method returns {@code false}, then you may want to page through the archive.
144 *
145 * {@link MamQuery} provides convince methods to do so: {@link MamQuery#pageNext(int)} and {@link MamQuery#pagePrevious(int)}.
146 *
147 * <pre>
148 * {@code
149 * MamQuery nextPageMamQuery = mamQuery.pageNext(10);
150 * }
151 * </pre>
152 *
153 * <h2>Get the supported form fields</h2>
154 *
155 * You can use {@link #retrieveFormFields()} to retrieve a list of the supported additional form fields by this archive.
156 * Those fields can be used for further restrict a query.
157 *
158 *
159 * @see <a href="http://xmpp.org/extensions/xep-0313.html">XEP-0313: Message
160 *      Archive Management</a>
161 * @author Florian Schmaus
162 * @author Fernando Ramirez
163 *
164 */
165public final class MamManager extends Manager {
166
167    static {
168        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
169            @Override
170            public void connectionCreated(XMPPConnection connection) {
171                getInstanceFor(connection);
172            }
173        });
174    }
175
176    private static final String FORM_FIELD_WITH = "with";
177    private static final String FORM_FIELD_START = "start";
178    private static final String FORM_FIELD_END = "end";
179
180    private static final Map<XMPPConnection, Map<Jid, MamManager>> INSTANCES = new WeakHashMap<>();
181
182    private static final String ADVANCED_CONFIG_NODE = "urn:xmpp:mam#configure";
183
184    /**
185     * Get a MamManager for the MAM archive of the local entity (the "user") of the given connection.
186     *
187     * @param connection the XMPP connection to get the archive for.
188     * @return the instance of MamManager.
189     */
190    // CHECKSTYLE:OFF:RegexpSingleline
191    public static MamManager getInstanceFor(XMPPConnection connection) {
192    // CHECKSTYLE:ON:RegexpSingleline
193        return getInstanceFor(connection, (Jid) null);
194    }
195
196    /**
197     * Get a MamManager for the MAM archive of the given {@code MultiUserChat}. Note that not all MUCs support MAM,
198     * hence it is recommended to use {@link #isSupported()} to check if MAM is supported by the MUC.
199     *
200     * @param multiUserChat the MultiUserChat to retrieve the MamManager for.
201     * @return the MamManager for the given MultiUserChat.
202     * @since 4.3.0
203     */
204    public static MamManager getInstanceFor(MultiUserChat multiUserChat) {
205        XMPPConnection connection = multiUserChat.getXmppConnection();
206        Jid archiveAddress = multiUserChat.getRoom();
207        return getInstanceFor(connection, archiveAddress);
208    }
209
210    public static synchronized MamManager getInstanceFor(XMPPConnection connection, Jid archiveAddress) {
211        Map<Jid, MamManager> managers = INSTANCES.get(connection);
212        if (managers == null) {
213            managers = new HashMap<>();
214            INSTANCES.put(connection, managers);
215        }
216        MamManager mamManager = managers.get(archiveAddress);
217        if (mamManager == null) {
218            mamManager = new MamManager(connection, archiveAddress);
219            managers.put(archiveAddress, mamManager);
220        }
221        return mamManager;
222    }
223
224    private final Jid archiveAddress;
225
226    private final ServiceDiscoveryManager serviceDiscoveryManager;
227
228    private final AdHocCommandManager adHocCommandManager;
229
230    private MamVersion mamVersion = null;
231
232    private MamManager(XMPPConnection connection, Jid archiveAddress) {
233        super(connection);
234        this.archiveAddress = archiveAddress;
235        serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(connection);
236        adHocCommandManager = AdHocCommandManager.getInstance(connection);
237    }
238
239    /**
240     * the XMPP address of this MAM archive. Note that this method may return {@code null} if this MamManager
241     * handles the local entity's archive and if the connection has never been authenticated at least once.
242     *
243     * @return the XMPP address of this MAM archive or {@code null}.
244     * @since 4.3.0
245     */
246    public Jid getArchiveAddress() {
247        if (archiveAddress == null) {
248            EntityFullJid localJid = connection().getUser();
249            if (localJid == null) {
250                return null;
251            }
252            return localJid.asBareJid();
253        }
254        return archiveAddress;
255    }
256
257    /**
258     * Returns the MAM namespace used by this {@link MamManager}. If the archive does not support any MAM namespace
259     * supported by Smack, null is returned.
260     *
261     * @return the MAM namespace used by this manager, null if MAM is not supported
262     * @throws NoResponseException if there was no response from the remote entity.
263     * @throws XMPPErrorException if there was an XMPP error returned.
264     * @throws NotConnectedException if the XMPP connection is not connected.
265     * @throws InterruptedException if the calling thread was interrupted.
266     */
267    public String getMamNamespace() throws XMPPErrorException, NotConnectedException, NoResponseException, InterruptedException {
268        MamVersion mamVersion = getSupportedMamVersionOrNull();
269        return mamVersion == null ? null : mamVersion.getNamespace();
270    }
271
272    private MamVersion getSupportedMamVersionOrNull() throws XMPPErrorException, NotConnectedException, NoResponseException, InterruptedException {
273        if (mamVersion != null) {
274            return mamVersion;
275        }
276
277        DiscoverInfo info = serviceDiscoveryManager.discoverInfo(getArchiveAddress());
278
279        // Enum values are always returned the order they are declared (see https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.9.3).
280        // We pick the first version supported by the server.
281        for (MamVersion v : MamVersion.values()) {
282            if (info.containsFeature(v.getNamespace())) {
283                mamVersion = v;
284                break;
285            }
286        }
287
288        return mamVersion;
289    }
290
291    private MamVersion getSupportedMamVersionOrThrow() throws XMPPErrorException, NotConnectedException, NoResponseException, InterruptedException {
292        MamVersion mamVersion = getSupportedMamVersionOrNull();
293        if (mamVersion == null) {
294            throw new UnsupportedOperationException("Message Archive Management is not supported by " + getArchiveAddress());
295        }
296        return mamVersion;
297    }
298
299    private MamElementFactory getElementFactory() throws XMPPErrorException, NotConnectedException, NoResponseException, InterruptedException {
300        return getSupportedMamVersionOrThrow().newElementFactory();
301    }
302
303    public static final class MamQueryArgs {
304        private final String node;
305
306        private final Map<String, FormField> formFields;
307
308        private final Integer maxResults;
309
310        private final String afterUid;
311
312        private final String beforeUid;
313
314        private MamQueryArgs(Builder builder) {
315            node = builder.node;
316            formFields = builder.formFields;
317            if (builder.maxResults > 0) {
318                maxResults = builder.maxResults;
319            } else {
320                maxResults = null;
321            }
322            afterUid = builder.afterUid;
323            beforeUid = builder.beforeUid;
324        }
325
326        private DataForm dataForm;
327
328        DataForm getDataForm(MamVersion version) {
329            if (dataForm != null) {
330                return dataForm;
331            }
332            DataForm.Builder dataFormBuilder = getNewMamForm(version);
333            dataFormBuilder.addFields(formFields.values());
334            dataForm = dataFormBuilder.build();
335            return dataForm;
336        }
337
338        void maybeAddRsmSet(MamQueryIQ mamQueryIQ) {
339            if (maxResults == null && afterUid == null && beforeUid == null) {
340                return;
341            }
342
343            int max;
344            if (maxResults != null) {
345                max = maxResults;
346            } else {
347                max = -1;
348            }
349
350            RSMSet rsmSet = new RSMSet(afterUid, beforeUid, -1, -1, null, max, null, -1);
351            mamQueryIQ.addExtension(rsmSet);
352        }
353
354        public static Builder builder() {
355            return new Builder();
356        }
357
358        public static final class Builder {
359            private String node;
360
361            private final Map<String, FormField> formFields = new LinkedHashMap<>(8);
362
363            private int maxResults = -1;
364
365            private String afterUid;
366
367            private String beforeUid;
368
369            public Builder queryNode(String node) {
370                if (node == null) {
371                    return this;
372                }
373
374                this.node = node;
375
376                return this;
377            }
378
379            public Builder limitResultsToJid(Jid withJid) {
380                if (withJid == null) {
381                    return this;
382                }
383
384                FormField formField = getWithFormField(withJid);
385                formFields.put(formField.getFieldName(), formField);
386
387                return this;
388            }
389
390            @SuppressWarnings("JavaUtilDate")
391            public Builder limitResultsSince(Date start) {
392                if (start == null) {
393                    return this;
394                }
395
396                FormField formField = FormField.builder(FORM_FIELD_START)
397                                .setValue(start)
398                                .build();
399                formFields.put(formField.getFieldName(), formField);
400
401                FormField endFormField = formFields.get(FORM_FIELD_END);
402                if (endFormField != null) {
403                    Date end;
404                    try {
405                        end = endFormField.getFirstValueAsDate();
406                    }
407                    catch (ParseException e) {
408                        throw new IllegalStateException(e);
409                    }
410                    if (end.getTime() <= start.getTime()) {
411                        throw new IllegalArgumentException("Given start date (" + start
412                                        + ") is after the existing end date (" + end + ')');
413                    }
414                }
415
416                return this;
417            }
418
419            @SuppressWarnings("JavaUtilDate")
420            public Builder limitResultsBefore(Date end) {
421                if (end == null) {
422                    return this;
423                }
424
425                FormField formField = FormField.builder(FORM_FIELD_END)
426                    .setValue(end)
427                    .build();
428                formFields.put(formField.getFieldName(), formField);
429
430                FormField startFormField = formFields.get(FORM_FIELD_START);
431                if (startFormField != null) {
432                    Date start;
433                    try {
434                        start = startFormField.getFirstValueAsDate();
435                    } catch (ParseException e) {
436                        throw new IllegalStateException(e);
437                    }
438                    if (end.getTime() <= start.getTime()) {
439                        throw new IllegalArgumentException("Given end date (" + end
440                                        + ") is before the existing start date (" + start + ')');
441                    }
442                }
443
444                return this;
445            }
446
447            public Builder setResultPageSize(Integer max) {
448                if (max == null) {
449                    maxResults = -1;
450                    return this;
451                }
452                return setResultPageSizeTo(max.intValue());
453            }
454
455            public Builder setResultPageSizeTo(int max) {
456                if (max < 0) {
457                    throw new IllegalArgumentException();
458                }
459                this.maxResults = max;
460                return this;
461            }
462
463            /**
464             * Only return the count of messages the query yields, not the actual messages. Note that not all services
465             * return a correct count, some return an approximate count.
466             *
467             * @return an reference to this builder.
468             * @see <a href="https://xmpp.org/extensions/xep-0059.html#count">XEP-0059 § 2.7</a>
469             */
470            public Builder onlyReturnMessageCount() {
471                return setResultPageSizeTo(0);
472            }
473
474            public Builder withAdditionalFormField(FormField formField) {
475                formFields.put(formField.getFieldName(), formField);
476                return this;
477            }
478
479            public Builder withAdditionalFormFields(List<FormField> additionalFields) {
480                for (FormField formField : additionalFields) {
481                    withAdditionalFormField(formField);
482                }
483                return this;
484            }
485
486            public Builder afterUid(String afterUid) {
487                this.afterUid = StringUtils.requireNullOrNotEmpty(afterUid, "afterUid must not be empty");
488                return this;
489            }
490
491            /**
492             * Specifies a message UID as 'before' anchor for the query. Note that unlike {@link #afterUid(String)} this
493             * method also accepts the empty String to query the last page of an archive (c.f. XEP-0059 § 2.5).
494             *
495             * @param beforeUid a message UID acting as 'before' query anchor.
496             * @return an instance to this builder.
497             */
498            public Builder beforeUid(String beforeUid) {
499                // We don't perform any argument validation, since every possible argument (null, empty string,
500                // non-empty string) is valid.
501                this.beforeUid = beforeUid;
502                return this;
503            }
504
505            /**
506             * Query from the last, i.e. most recent, page of the archive. This will return the very last page of the
507             * archive holding the most recent matching messages. You usually would page backwards from there on.
508             *
509             * @return a reference to this builder.
510             * @see <a href="https://xmpp.org/extensions/xep-0059.html#last">XEP-0059 § 2.5. Requesting the Last Page in
511             *      a Result Set</a>
512             */
513            public Builder queryLastPage() {
514                return beforeUid("");
515            }
516
517            public MamQueryArgs build() {
518                return new MamQueryArgs(this);
519            }
520        }
521    }
522
523    public MamQuery queryArchive(MamQueryArgs mamQueryArgs) throws NoResponseException, XMPPErrorException,
524                    NotConnectedException, NotLoggedInException, InterruptedException {
525        String queryId = StringUtils.secureUniqueRandomString();
526        String node = mamQueryArgs.node;
527        DataForm dataForm = mamQueryArgs.getDataForm(mamVersion);
528
529        MamQueryIQ mamQueryIQ = getElementFactory().newQueryIQ(queryId, node, dataForm);
530        mamQueryIQ.setType(IQ.Type.set);
531        mamQueryIQ.setTo(archiveAddress);
532
533        mamQueryArgs.maybeAddRsmSet(mamQueryIQ);
534
535        return queryArchive(mamQueryIQ);
536    }
537
538    private static FormField getWithFormField(Jid withJid) {
539        return FormField.builder(FORM_FIELD_WITH)
540                        .setValue(withJid.toString())
541                        .build();
542    }
543
544    public MamQuery queryMostRecentPage(Jid jid, int max) throws NoResponseException, XMPPErrorException,
545                    NotConnectedException, NotLoggedInException, InterruptedException {
546        MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
547                        // Produces an empty <before/> element for XEP-0059 § 2.5
548                        .queryLastPage()
549                        .limitResultsToJid(jid)
550                        .setResultPageSize(max)
551                        .build();
552        return queryArchive(mamQueryArgs);
553    }
554
555    /**
556     * Get the form fields supported by the server.
557     *
558     * @return the list of form fields.
559     * @throws NoResponseException if there was no response from the remote entity.
560     * @throws XMPPErrorException if there was an XMPP error returned.
561     * @throws NotConnectedException if the XMPP connection is not connected.
562     * @throws InterruptedException if the calling thread was interrupted.
563     * @throws NotLoggedInException if the XMPP connection is not authenticated.
564     */
565    public List<FormField> retrieveFormFields() throws NoResponseException, XMPPErrorException, NotConnectedException,
566                    InterruptedException, NotLoggedInException {
567        return retrieveFormFields(null);
568    }
569
570    /**
571     * Get the form fields supported by the server.
572     *
573     * @param node The PubSub node name, can be null
574     * @return the list of form fields.
575     * @throws NoResponseException if there was no response from the remote entity.
576     * @throws XMPPErrorException if there was an XMPP error returned.
577     * @throws NotConnectedException if the XMPP connection is not connected.
578     * @throws InterruptedException if the calling thread was interrupted.
579     * @throws NotLoggedInException if the XMPP connection is not authenticated.
580     */
581    public List<FormField> retrieveFormFields(String node)
582                    throws NoResponseException, XMPPErrorException, NotConnectedException,
583            InterruptedException, NotLoggedInException {
584        String queryId = StringUtils.secureUniqueRandomString();
585        MamQueryIQ mamQueryIq = getElementFactory().newQueryIQ(queryId, node, null);
586        mamQueryIq.setTo(archiveAddress);
587
588        MamQueryIQ mamResponseQueryIq = connection().sendIqRequestAndWaitForResponse(mamQueryIq);
589
590        return mamResponseQueryIq.getDataForm().getFields();
591    }
592
593    private MamQuery queryArchive(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException,
594                    NotConnectedException, InterruptedException, NotLoggedInException {
595        MamQueryPage mamQueryPage = queryArchivePage(mamQueryIq);
596        return new MamQuery(mamQueryPage, mamQueryIq.getNode(), DataForm.from(mamQueryIq));
597    }
598
599    private MamQueryPage queryArchivePage(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException,
600                    NotConnectedException, InterruptedException, NotLoggedInException {
601        final XMPPConnection connection = getAuthenticatedConnectionOrThrow();
602        MamFinIQ mamFinIQ;
603
604        StanzaCollector mamFinIQCollector = connection.createStanzaCollector(new IQReplyFilter(mamQueryIq, connection));
605
606        StanzaCollector.Configuration resultCollectorConfiguration = StanzaCollector.newConfiguration()
607                .setStanzaFilter(new MamResultFilter(mamQueryIq)).setCollectorToReset(mamFinIQCollector);
608
609        StanzaCollector cancelledResultCollector;
610        try (StanzaCollector resultCollector = connection.createStanzaCollector(resultCollectorConfiguration)) {
611            connection.sendStanza(mamQueryIq);
612            mamFinIQ = mamFinIQCollector.nextResultOrThrow();
613            cancelledResultCollector = resultCollector;
614        }
615
616        return new MamQueryPage(cancelledResultCollector, mamFinIQ);
617    }
618
619    public final class MamQuery {
620        private final String node;
621        private final DataForm form;
622
623        private MamQueryPage mamQueryPage;
624
625        private MamQuery(MamQueryPage mamQueryPage, String node, DataForm form) {
626            this.node = node;
627            this.form = form;
628
629            this.mamQueryPage = mamQueryPage;
630        }
631
632        public boolean isComplete() {
633            return mamQueryPage.getMamFinIq().isComplete();
634        }
635
636        public List<Message> getMessages() {
637            return mamQueryPage.messages;
638        }
639
640        public List<MamResultExtension> getMamResultExtensions() {
641            return mamQueryPage.mamResultExtensions;
642        }
643
644        private List<Message> page(RSMSet requestRsmSet) throws NoResponseException, XMPPErrorException,
645                        NotConnectedException, NotLoggedInException, InterruptedException {
646            String queryId = StringUtils.secureUniqueRandomString();
647            MamQueryIQ mamQueryIQ = getElementFactory().newQueryIQ(queryId, node, form);
648            mamQueryIQ.setType(IQ.Type.set);
649            mamQueryIQ.setTo(archiveAddress);
650            mamQueryIQ.addExtension(requestRsmSet);
651
652            mamQueryPage = queryArchivePage(mamQueryIQ);
653
654            return mamQueryPage.messages;
655        }
656
657        private RSMSet getPreviousRsmSet() {
658            return mamQueryPage.getMamFinIq().getRSMSet();
659        }
660
661        public List<Message> pageNext(int count) throws NoResponseException, XMPPErrorException, NotConnectedException,
662                        NotLoggedInException, InterruptedException {
663            RSMSet previousResultRsmSet = getPreviousRsmSet();
664            RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getLast(), RSMSet.PageDirection.after);
665            return page(requestRsmSet);
666        }
667
668        public List<Message> pagePrevious(int count) throws NoResponseException, XMPPErrorException,
669                        NotConnectedException, NotLoggedInException, InterruptedException {
670            RSMSet previousResultRsmSet = getPreviousRsmSet();
671            RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getFirst(), RSMSet.PageDirection.before);
672            return page(requestRsmSet);
673        }
674
675        public int getMessageCount() {
676            return getMessages().size();
677        }
678
679        public MamQueryPage getPage() {
680            return mamQueryPage;
681        }
682    }
683
684    public static final class MamQueryPage {
685        private final MamFinIQ mamFin;
686        private final List<Message> mamResultCarrierMessages;
687        private final List<MamResultExtension> mamResultExtensions;
688        private final List<Forwarded<Message>> forwardedMessages;
689        private final List<Message> messages;
690
691        private MamQueryPage(StanzaCollector stanzaCollector, MamFinIQ mamFin) {
692            this.mamFin = mamFin;
693
694            List<Stanza> mamResultCarrierStanzas = stanzaCollector.getCollectedStanzasAfterCancelled();
695
696            List<Message> mamResultCarrierMessages = new ArrayList<>(mamResultCarrierStanzas.size());
697            List<MamResultExtension> mamResultExtensions = new ArrayList<>(mamResultCarrierStanzas.size());
698            List<Forwarded<Message>> forwardedMessages = new ArrayList<>(mamResultCarrierStanzas.size());
699
700            for (Stanza mamResultStanza : mamResultCarrierStanzas) {
701                Message resultMessage = (Message) mamResultStanza;
702
703                mamResultCarrierMessages.add(resultMessage);
704
705                MamElements.MamResultExtension mamResultExtension = MamElements.MamResultExtension.from(resultMessage);
706                mamResultExtensions.add(mamResultExtension);
707
708                forwardedMessages.add(mamResultExtension.getForwarded());
709            }
710
711            this.mamResultCarrierMessages = Collections.unmodifiableList(mamResultCarrierMessages);
712            this.mamResultExtensions = Collections.unmodifiableList(mamResultExtensions);
713            this.forwardedMessages = Collections.unmodifiableList(forwardedMessages);
714            this.messages = Collections.unmodifiableList(Forwarded.extractMessagesFrom(forwardedMessages));
715        }
716
717        public List<Message> getMessages() {
718            return messages;
719        }
720
721        public List<Forwarded<Message>> getForwarded() {
722            return forwardedMessages;
723        }
724
725        public List<MamResultExtension> getMamResultExtensions() {
726            return mamResultExtensions;
727        }
728
729        public List<Message> getMamResultCarrierMessages() {
730            return mamResultCarrierMessages;
731        }
732
733        public MamFinIQ getMamFinIq() {
734            return mamFin;
735        }
736    }
737
738    /**
739     * Check if this MamManager's archive address supports MAM.
740     *
741     * @return true if MAM is supported, <code>false</code>otherwise.
742     *
743     * @throws NoResponseException if there was no response from the remote entity.
744     * @throws XMPPErrorException if there was an XMPP error returned.
745     * @throws NotConnectedException if the XMPP connection is not connected.
746     * @throws InterruptedException if the calling thread was interrupted.
747     * @since 4.2.1
748     * @see <a href="https://xmpp.org/extensions/xep-0313.html#support">XEP-0313 § 7. Determining support</a>
749     */
750    public boolean isSupported() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
751        return getSupportedMamVersionOrNull() != null;
752    }
753
754    public boolean isAdvancedConfigurationSupported() throws InterruptedException, XMPPException, SmackException {
755        DiscoverItems discoverItems = adHocCommandManager.discoverCommands(archiveAddress);
756        for (DiscoverItems.Item item : discoverItems.getItems()) {
757            if (item.getNode().equals(ADVANCED_CONFIG_NODE)) {
758                return true;
759            }
760        }
761        return false;
762    }
763
764    public AdHocCommand getAdvancedConfigurationCommand() throws InterruptedException, XMPPException, SmackException {
765        DiscoverItems discoverItems = adHocCommandManager.discoverCommands(archiveAddress);
766        for (DiscoverItems.Item item : discoverItems.getItems()) {
767            if (item.getNode().equals(ADVANCED_CONFIG_NODE))
768                return adHocCommandManager.getRemoteCommand(archiveAddress, item.getNode());
769        }
770        throw new SmackException.FeatureNotSupportedException(ADVANCED_CONFIG_NODE, archiveAddress);
771    }
772
773    private static DataForm.Builder getNewMamForm(MamVersion version) {
774        FormField field = FormField.buildHiddenFormType(version.getNamespace());
775        DataForm.Builder form = DataForm.builder();
776        form.addField(field);
777        return form;
778    }
779
780    /**
781     * Lookup the archive's message ID of the latest message in the archive. Returns {@code null} if the archive is
782     * empty.
783     *
784     * @return the ID of the lastest message or {@code null}.
785     * @throws NoResponseException if there was no response from the remote entity.
786     * @throws XMPPErrorException if there was an XMPP error returned.
787     * @throws NotConnectedException if the XMPP connection is not connected.
788     * @throws NotLoggedInException if the XMPP connection is not authenticated.
789     * @throws InterruptedException if the calling thread was interrupted.
790     * @since 4.3.0
791     */
792    public String getMessageUidOfLatestMessage() throws NoResponseException, XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException {
793        MamQueryArgs mamQueryArgs = MamQueryArgs.builder()
794                .setResultPageSize(1)
795                .queryLastPage()
796                .build();
797
798        MamQuery mamQuery = queryArchive(mamQueryArgs);
799        if (mamQuery.getMessages().isEmpty()) {
800            return null;
801        }
802
803        return mamQuery.getMamResultExtensions().get(0).getId();
804    }
805
806    /**
807     * Get the preferences stored in the server.
808     *
809     * @return the MAM preferences result
810     * @throws NoResponseException if there was no response from the remote entity.
811     * @throws XMPPErrorException if there was an XMPP error returned.
812     * @throws NotConnectedException if the XMPP connection is not connected.
813     * @throws InterruptedException if the calling thread was interrupted.
814     * @throws NotLoggedInException if the XMPP connection is not authenticated.
815     */
816    public MamPrefsResult retrieveArchivingPreferences() throws NoResponseException, XMPPErrorException,
817            NotConnectedException, InterruptedException, NotLoggedInException {
818        MamPrefsIQ mamPrefIQ = getElementFactory().newPrefsIQ();
819        return queryMamPrefs(mamPrefIQ);
820    }
821
822    /**
823     * Update the preferences in the server.
824     *
825     * @param mamPrefs the MAM preferences to set the archive to
826     * @return the currently active preferences after the operation.
827     * @throws NoResponseException if there was no response from the remote entity.
828     * @throws XMPPErrorException if there was an XMPP error returned.
829     * @throws NotConnectedException if the XMPP connection is not connected.
830     * @throws InterruptedException if the calling thread was interrupted.
831     * @throws NotLoggedInException if the XMPP connection is not authenticated.
832     * @since 4.3.0
833     */
834    public MamPrefsResult updateArchivingPreferences(MamPrefs mamPrefs) throws NoResponseException, XMPPErrorException,
835                    NotConnectedException, InterruptedException, NotLoggedInException {
836        MamPrefsIQ mamPrefIQ = mamPrefs.constructMamPrefsIq();
837        return queryMamPrefs(mamPrefIQ);
838    }
839
840    public MamPrefsResult enableMamForAllMessages() throws NoResponseException, XMPPErrorException,
841                    NotConnectedException, NotLoggedInException, InterruptedException {
842        return setDefaultBehavior(DefaultBehavior.always);
843    }
844
845    public MamPrefsResult enableMamForRosterMessages() throws NoResponseException, XMPPErrorException,
846                    NotConnectedException, NotLoggedInException, InterruptedException {
847        return setDefaultBehavior(DefaultBehavior.roster);
848    }
849
850    public MamPrefsResult setDefaultBehavior(DefaultBehavior desiredDefaultBehavior) throws NoResponseException,
851                    XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException {
852        MamPrefsResult mamPrefsResult = retrieveArchivingPreferences();
853        if (mamPrefsResult.mamPrefs.getDefault() == desiredDefaultBehavior) {
854            return mamPrefsResult;
855        }
856
857        MamPrefs mamPrefs = mamPrefsResult.asMamPrefs();
858        mamPrefs.setDefaultBehavior(desiredDefaultBehavior);
859        return updateArchivingPreferences(mamPrefs);
860    }
861
862    /**
863     * MAM preferences result class.
864     *
865     */
866    public static final class MamPrefsResult {
867        public final MamPrefsIQ mamPrefs;
868        public final DataForm form;
869
870        private MamPrefsResult(MamPrefsIQ mamPrefs, DataForm form) {
871            this.mamPrefs = mamPrefs;
872            this.form = form;
873        }
874
875        public MamPrefs asMamPrefs() {
876            return new MamPrefs(this);
877        }
878    }
879
880    public static final class MamPrefs {
881        private final List<Jid> alwaysJids;
882        private final List<Jid> neverJids;
883        private final MamVersion mamVersion;
884        private DefaultBehavior defaultBehavior;
885
886        private MamPrefs(MamPrefsResult mamPrefsResult) {
887            MamPrefsIQ mamPrefsIq = mamPrefsResult.mamPrefs;
888            this.alwaysJids = new ArrayList<>(mamPrefsIq.getAlwaysJids());
889            this.neverJids = new ArrayList<>(mamPrefsIq.getNeverJids());
890            this.defaultBehavior = mamPrefsIq.getDefault();
891            this.mamVersion = MamVersion.fromNamespace(mamPrefsIq.getNamespace());
892        }
893
894        public void setDefaultBehavior(DefaultBehavior defaultBehavior) {
895            this.defaultBehavior = Objects.requireNonNull(defaultBehavior, "defaultBehavior must not be null");
896        }
897
898        public DefaultBehavior getDefaultBehavior() {
899            return defaultBehavior;
900        }
901
902        public List<Jid> getAlwaysJids() {
903            return alwaysJids;
904        }
905
906        public List<Jid> getNeverJids() {
907            return neverJids;
908        }
909
910        private MamPrefsIQ constructMamPrefsIq() {
911            return mamVersion.newElementFactory().newPrefsIQ(alwaysJids, neverJids, defaultBehavior);
912        }
913    }
914
915    private MamPrefsResult queryMamPrefs(MamPrefsIQ mamPrefsIQ) throws NoResponseException, XMPPErrorException,
916            NotConnectedException, InterruptedException, NotLoggedInException {
917        final XMPPConnection connection = getAuthenticatedConnectionOrThrow();
918
919        MamPrefsIQ mamPrefsResultIQ = connection.sendIqRequestAndWaitForResponse(mamPrefsIQ);
920
921        return new MamPrefsResult(mamPrefsResultIQ, DataForm.from(mamPrefsIQ));
922    }
923
924}