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