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.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.getAddHocCommandsManager(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 public Builder limitResultsSince(Date start) { 391 if (start == null) { 392 return this; 393 } 394 395 FormField formField = FormField.builder(FORM_FIELD_START) 396 .setValue(start) 397 .build(); 398 formFields.put(formField.getFieldName(), formField); 399 400 FormField endFormField = formFields.get(FORM_FIELD_END); 401 if (endFormField != null) { 402 Date end; 403 try { 404 end = endFormField.getFirstValueAsDate(); 405 } 406 catch (ParseException e) { 407 throw new IllegalStateException(e); 408 } 409 if (end.getTime() <= start.getTime()) { 410 throw new IllegalArgumentException("Given start date (" + start 411 + ") is after the existing end date (" + end + ')'); 412 } 413 } 414 415 return this; 416 } 417 418 public Builder limitResultsBefore(Date end) { 419 if (end == null) { 420 return this; 421 } 422 423 FormField formField = FormField.builder(FORM_FIELD_END) 424 .setValue(end) 425 .build(); 426 formFields.put(formField.getFieldName(), formField); 427 428 FormField startFormField = formFields.get(FORM_FIELD_START); 429 if (startFormField != null) { 430 Date start; 431 try { 432 start = startFormField.getFirstValueAsDate(); 433 } catch (ParseException e) { 434 throw new IllegalStateException(e); 435 } 436 if (end.getTime() <= start.getTime()) { 437 throw new IllegalArgumentException("Given end date (" + end 438 + ") is before the existing start date (" + start + ')'); 439 } 440 } 441 442 return this; 443 } 444 445 public Builder setResultPageSize(Integer max) { 446 if (max == null) { 447 maxResults = -1; 448 return this; 449 } 450 return setResultPageSizeTo(max.intValue()); 451 } 452 453 public Builder setResultPageSizeTo(int max) { 454 if (max < 0) { 455 throw new IllegalArgumentException(); 456 } 457 this.maxResults = max; 458 return this; 459 } 460 461 /** 462 * Only return the count of messages the query yields, not the actual messages. Note that not all services 463 * return a correct count, some return an approximate count. 464 * 465 * @return an reference to this builder. 466 * @see <a href="https://xmpp.org/extensions/xep-0059.html#count">XEP-0059 § 2.7</a> 467 */ 468 public Builder onlyReturnMessageCount() { 469 return setResultPageSizeTo(0); 470 } 471 472 public Builder withAdditionalFormField(FormField formField) { 473 formFields.put(formField.getFieldName(), formField); 474 return this; 475 } 476 477 public Builder withAdditionalFormFields(List<FormField> additionalFields) { 478 for (FormField formField : additionalFields) { 479 withAdditionalFormField(formField); 480 } 481 return this; 482 } 483 484 public Builder afterUid(String afterUid) { 485 this.afterUid = StringUtils.requireNullOrNotEmpty(afterUid, "afterUid must not be empty"); 486 return this; 487 } 488 489 /** 490 * Specifies a message UID as 'before' anchor for the query. Note that unlike {@link #afterUid(String)} this 491 * method also accepts the empty String to query the last page of an archive (c.f. XEP-0059 § 2.5). 492 * 493 * @param beforeUid a message UID acting as 'before' query anchor. 494 * @return an instance to this builder. 495 */ 496 public Builder beforeUid(String beforeUid) { 497 // We don't perform any argument validation, since every possible argument (null, empty string, 498 // non-empty string) is valid. 499 this.beforeUid = beforeUid; 500 return this; 501 } 502 503 /** 504 * Query from the last, i.e. most recent, page of the archive. This will return the very last page of the 505 * archive holding the most recent matching messages. You usually would page backwards from there on. 506 * 507 * @return a reference to this builder. 508 * @see <a href="https://xmpp.org/extensions/xep-0059.html#last">XEP-0059 § 2.5. Requesting the Last Page in 509 * a Result Set</a> 510 */ 511 public Builder queryLastPage() { 512 return beforeUid(""); 513 } 514 515 public MamQueryArgs build() { 516 return new MamQueryArgs(this); 517 } 518 } 519 } 520 521 public MamQuery queryArchive(MamQueryArgs mamQueryArgs) throws NoResponseException, XMPPErrorException, 522 NotConnectedException, NotLoggedInException, InterruptedException { 523 String queryId = StringUtils.secureUniqueRandomString(); 524 String node = mamQueryArgs.node; 525 DataForm dataForm = mamQueryArgs.getDataForm(mamVersion); 526 527 MamQueryIQ mamQueryIQ = getElementFactory().newQueryIQ(queryId, node, dataForm); 528 mamQueryIQ.setType(IQ.Type.set); 529 mamQueryIQ.setTo(archiveAddress); 530 531 mamQueryArgs.maybeAddRsmSet(mamQueryIQ); 532 533 return queryArchive(mamQueryIQ); 534 } 535 536 private static FormField getWithFormField(Jid withJid) { 537 return FormField.builder(FORM_FIELD_WITH) 538 .setValue(withJid.toString()) 539 .build(); 540 } 541 542 public MamQuery queryMostRecentPage(Jid jid, int max) throws NoResponseException, XMPPErrorException, 543 NotConnectedException, NotLoggedInException, InterruptedException { 544 MamQueryArgs mamQueryArgs = MamQueryArgs.builder() 545 // Produces an empty <before/> element for XEP-0059 § 2.5 546 .queryLastPage() 547 .limitResultsToJid(jid) 548 .setResultPageSize(max) 549 .build(); 550 return queryArchive(mamQueryArgs); 551 } 552 553 /** 554 * Get the form fields supported by the server. 555 * 556 * @return the list of form fields. 557 * @throws NoResponseException if there was no response from the remote entity. 558 * @throws XMPPErrorException if there was an XMPP error returned. 559 * @throws NotConnectedException if the XMPP connection is not connected. 560 * @throws InterruptedException if the calling thread was interrupted. 561 * @throws NotLoggedInException if the XMPP connection is not authenticated. 562 */ 563 public List<FormField> retrieveFormFields() throws NoResponseException, XMPPErrorException, NotConnectedException, 564 InterruptedException, NotLoggedInException { 565 return retrieveFormFields(null); 566 } 567 568 /** 569 * Get the form fields supported by the server. 570 * 571 * @param node The PubSub node name, can be null 572 * @return the list of form fields. 573 * @throws NoResponseException if there was no response from the remote entity. 574 * @throws XMPPErrorException if there was an XMPP error returned. 575 * @throws NotConnectedException if the XMPP connection is not connected. 576 * @throws InterruptedException if the calling thread was interrupted. 577 * @throws NotLoggedInException if the XMPP connection is not authenticated. 578 */ 579 public List<FormField> retrieveFormFields(String node) 580 throws NoResponseException, XMPPErrorException, NotConnectedException, 581 InterruptedException, NotLoggedInException { 582 String queryId = StringUtils.secureUniqueRandomString(); 583 MamQueryIQ mamQueryIq = getElementFactory().newQueryIQ(queryId, node, null); 584 mamQueryIq.setTo(archiveAddress); 585 586 MamQueryIQ mamResponseQueryIq = connection().sendIqRequestAndWaitForResponse(mamQueryIq); 587 588 return mamResponseQueryIq.getDataForm().getFields(); 589 } 590 591 private MamQuery queryArchive(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException, 592 NotConnectedException, InterruptedException, NotLoggedInException { 593 MamQueryPage mamQueryPage = queryArchivePage(mamQueryIq); 594 return new MamQuery(mamQueryPage, mamQueryIq.getNode(), DataForm.from(mamQueryIq)); 595 } 596 597 private MamQueryPage queryArchivePage(MamQueryIQ mamQueryIq) throws NoResponseException, XMPPErrorException, 598 NotConnectedException, InterruptedException, NotLoggedInException { 599 final XMPPConnection connection = getAuthenticatedConnectionOrThrow(); 600 MamFinIQ mamFinIQ; 601 602 StanzaCollector mamFinIQCollector = connection.createStanzaCollector(new IQReplyFilter(mamQueryIq, connection)); 603 604 StanzaCollector.Configuration resultCollectorConfiguration = StanzaCollector.newConfiguration() 605 .setStanzaFilter(new MamResultFilter(mamQueryIq)).setCollectorToReset(mamFinIQCollector); 606 607 StanzaCollector cancelledResultCollector; 608 try (StanzaCollector resultCollector = connection.createStanzaCollector(resultCollectorConfiguration)) { 609 connection.sendStanza(mamQueryIq); 610 mamFinIQ = mamFinIQCollector.nextResultOrThrow(); 611 cancelledResultCollector = resultCollector; 612 } 613 614 return new MamQueryPage(cancelledResultCollector, mamFinIQ); 615 } 616 617 public final class MamQuery { 618 private final String node; 619 private final DataForm form; 620 621 private MamQueryPage mamQueryPage; 622 623 private MamQuery(MamQueryPage mamQueryPage, String node, DataForm form) { 624 this.node = node; 625 this.form = form; 626 627 this.mamQueryPage = mamQueryPage; 628 } 629 630 public boolean isComplete() { 631 return mamQueryPage.getMamFinIq().isComplete(); 632 } 633 634 public List<Message> getMessages() { 635 return mamQueryPage.messages; 636 } 637 638 public List<MamResultExtension> getMamResultExtensions() { 639 return mamQueryPage.mamResultExtensions; 640 } 641 642 private List<Message> page(RSMSet requestRsmSet) throws NoResponseException, XMPPErrorException, 643 NotConnectedException, NotLoggedInException, InterruptedException { 644 String queryId = StringUtils.secureUniqueRandomString(); 645 MamQueryIQ mamQueryIQ = getElementFactory().newQueryIQ(queryId, node, form); 646 mamQueryIQ.setType(IQ.Type.set); 647 mamQueryIQ.setTo(archiveAddress); 648 mamQueryIQ.addExtension(requestRsmSet); 649 650 mamQueryPage = queryArchivePage(mamQueryIQ); 651 652 return mamQueryPage.messages; 653 } 654 655 private RSMSet getPreviousRsmSet() { 656 return mamQueryPage.getMamFinIq().getRSMSet(); 657 } 658 659 public List<Message> pageNext(int count) throws NoResponseException, XMPPErrorException, NotConnectedException, 660 NotLoggedInException, InterruptedException { 661 RSMSet previousResultRsmSet = getPreviousRsmSet(); 662 RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getLast(), RSMSet.PageDirection.after); 663 return page(requestRsmSet); 664 } 665 666 public List<Message> pagePrevious(int count) throws NoResponseException, XMPPErrorException, 667 NotConnectedException, NotLoggedInException, InterruptedException { 668 RSMSet previousResultRsmSet = getPreviousRsmSet(); 669 RSMSet requestRsmSet = new RSMSet(count, previousResultRsmSet.getFirst(), RSMSet.PageDirection.before); 670 return page(requestRsmSet); 671 } 672 673 public int getMessageCount() { 674 return getMessages().size(); 675 } 676 677 public MamQueryPage getPage() { 678 return mamQueryPage; 679 } 680 } 681 682 public static final class MamQueryPage { 683 private final MamFinIQ mamFin; 684 private final List<Message> mamResultCarrierMessages; 685 private final List<MamResultExtension> mamResultExtensions; 686 private final List<Forwarded<Message>> forwardedMessages; 687 private final List<Message> messages; 688 689 private MamQueryPage(StanzaCollector stanzaCollector, MamFinIQ mamFin) { 690 this.mamFin = mamFin; 691 692 List<Stanza> mamResultCarrierStanzas = stanzaCollector.getCollectedStanzasAfterCancelled(); 693 694 List<Message> mamResultCarrierMessages = new ArrayList<>(mamResultCarrierStanzas.size()); 695 List<MamResultExtension> mamResultExtensions = new ArrayList<>(mamResultCarrierStanzas.size()); 696 List<Forwarded<Message>> forwardedMessages = new ArrayList<>(mamResultCarrierStanzas.size()); 697 698 for (Stanza mamResultStanza : mamResultCarrierStanzas) { 699 Message resultMessage = (Message) mamResultStanza; 700 701 mamResultCarrierMessages.add(resultMessage); 702 703 MamElements.MamResultExtension mamResultExtension = MamElements.MamResultExtension.from(resultMessage); 704 mamResultExtensions.add(mamResultExtension); 705 706 forwardedMessages.add(mamResultExtension.getForwarded()); 707 } 708 709 this.mamResultCarrierMessages = Collections.unmodifiableList(mamResultCarrierMessages); 710 this.mamResultExtensions = Collections.unmodifiableList(mamResultExtensions); 711 this.forwardedMessages = Collections.unmodifiableList(forwardedMessages); 712 this.messages = Collections.unmodifiableList(Forwarded.extractMessagesFrom(forwardedMessages)); 713 } 714 715 public List<Message> getMessages() { 716 return messages; 717 } 718 719 public List<Forwarded<Message>> getForwarded() { 720 return forwardedMessages; 721 } 722 723 public List<MamResultExtension> getMamResultExtensions() { 724 return mamResultExtensions; 725 } 726 727 public List<Message> getMamResultCarrierMessages() { 728 return mamResultCarrierMessages; 729 } 730 731 public MamFinIQ getMamFinIq() { 732 return mamFin; 733 } 734 } 735 736 /** 737 * Check if this MamManager's archive address supports MAM. 738 * 739 * @return true if MAM is supported, <code>false</code>otherwise. 740 * 741 * @throws NoResponseException if there was no response from the remote entity. 742 * @throws XMPPErrorException if there was an XMPP error returned. 743 * @throws NotConnectedException if the XMPP connection is not connected. 744 * @throws InterruptedException if the calling thread was interrupted. 745 * @since 4.2.1 746 * @see <a href="https://xmpp.org/extensions/xep-0313.html#support">XEP-0313 § 7. Determining support</a> 747 */ 748 public boolean isSupported() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 749 return getSupportedMamVersionOrNull() != null; 750 } 751 752 public boolean isAdvancedConfigurationSupported() throws InterruptedException, XMPPException, SmackException { 753 DiscoverItems discoverItems = adHocCommandManager.discoverCommands(archiveAddress); 754 for (DiscoverItems.Item item : discoverItems.getItems()) { 755 if (item.getNode().equals(ADVANCED_CONFIG_NODE)) { 756 return true; 757 } 758 } 759 return false; 760 } 761 762 public RemoteCommand getAdvancedConfigurationCommand() throws InterruptedException, XMPPException, SmackException { 763 DiscoverItems discoverItems = adHocCommandManager.discoverCommands(archiveAddress); 764 for (DiscoverItems.Item item : discoverItems.getItems()) { 765 if (item.getNode().equals(ADVANCED_CONFIG_NODE)) 766 return adHocCommandManager.getRemoteCommand(archiveAddress, item.getNode()); 767 } 768 throw new SmackException.FeatureNotSupportedException(ADVANCED_CONFIG_NODE, archiveAddress); 769 } 770 771 private static DataForm.Builder getNewMamForm(MamVersion version) { 772 FormField field = FormField.buildHiddenFormType(version.getNamespace()); 773 DataForm.Builder form = DataForm.builder(); 774 form.addField(field); 775 return form; 776 } 777 778 /** 779 * Lookup the archive's message ID of the latest message in the archive. Returns {@code null} if the archive is 780 * empty. 781 * 782 * @return the ID of the lastest message or {@code null}. 783 * @throws NoResponseException if there was no response from the remote entity. 784 * @throws XMPPErrorException if there was an XMPP error returned. 785 * @throws NotConnectedException if the XMPP connection is not connected. 786 * @throws NotLoggedInException if the XMPP connection is not authenticated. 787 * @throws InterruptedException if the calling thread was interrupted. 788 * @since 4.3.0 789 */ 790 public String getMessageUidOfLatestMessage() throws NoResponseException, XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException { 791 MamQueryArgs mamQueryArgs = MamQueryArgs.builder() 792 .setResultPageSize(1) 793 .queryLastPage() 794 .build(); 795 796 MamQuery mamQuery = queryArchive(mamQueryArgs); 797 if (mamQuery.getMessages().isEmpty()) { 798 return null; 799 } 800 801 return mamQuery.getMamResultExtensions().get(0).getId(); 802 } 803 804 /** 805 * Get the preferences stored in the server. 806 * 807 * @return the MAM preferences result 808 * @throws NoResponseException if there was no response from the remote entity. 809 * @throws XMPPErrorException if there was an XMPP error returned. 810 * @throws NotConnectedException if the XMPP connection is not connected. 811 * @throws InterruptedException if the calling thread was interrupted. 812 * @throws NotLoggedInException if the XMPP connection is not authenticated. 813 */ 814 public MamPrefsResult retrieveArchivingPreferences() throws NoResponseException, XMPPErrorException, 815 NotConnectedException, InterruptedException, NotLoggedInException { 816 MamPrefsIQ mamPrefIQ = getElementFactory().newPrefsIQ(); 817 return queryMamPrefs(mamPrefIQ); 818 } 819 820 /** 821 * Update the preferences in the server. 822 * 823 * @param mamPrefs the MAM preferences to set the archive to 824 * @return the currently active preferences after the operation. 825 * @throws NoResponseException if there was no response from the remote entity. 826 * @throws XMPPErrorException if there was an XMPP error returned. 827 * @throws NotConnectedException if the XMPP connection is not connected. 828 * @throws InterruptedException if the calling thread was interrupted. 829 * @throws NotLoggedInException if the XMPP connection is not authenticated. 830 * @since 4.3.0 831 */ 832 public MamPrefsResult updateArchivingPreferences(MamPrefs mamPrefs) throws NoResponseException, XMPPErrorException, 833 NotConnectedException, InterruptedException, NotLoggedInException { 834 MamPrefsIQ mamPrefIQ = mamPrefs.constructMamPrefsIq(); 835 return queryMamPrefs(mamPrefIQ); 836 } 837 838 public MamPrefsResult enableMamForAllMessages() throws NoResponseException, XMPPErrorException, 839 NotConnectedException, NotLoggedInException, InterruptedException { 840 return setDefaultBehavior(DefaultBehavior.always); 841 } 842 843 public MamPrefsResult enableMamForRosterMessages() throws NoResponseException, XMPPErrorException, 844 NotConnectedException, NotLoggedInException, InterruptedException { 845 return setDefaultBehavior(DefaultBehavior.roster); 846 } 847 848 public MamPrefsResult setDefaultBehavior(DefaultBehavior desiredDefaultBehavior) throws NoResponseException, 849 XMPPErrorException, NotConnectedException, NotLoggedInException, InterruptedException { 850 MamPrefsResult mamPrefsResult = retrieveArchivingPreferences(); 851 if (mamPrefsResult.mamPrefs.getDefault() == desiredDefaultBehavior) { 852 return mamPrefsResult; 853 } 854 855 MamPrefs mamPrefs = mamPrefsResult.asMamPrefs(); 856 mamPrefs.setDefaultBehavior(desiredDefaultBehavior); 857 return updateArchivingPreferences(mamPrefs); 858 } 859 860 /** 861 * MAM preferences result class. 862 * 863 */ 864 public static final class MamPrefsResult { 865 public final MamPrefsIQ mamPrefs; 866 public final DataForm form; 867 868 private MamPrefsResult(MamPrefsIQ mamPrefs, DataForm form) { 869 this.mamPrefs = mamPrefs; 870 this.form = form; 871 } 872 873 public MamPrefs asMamPrefs() { 874 return new MamPrefs(this); 875 } 876 } 877 878 public static final class MamPrefs { 879 private final List<Jid> alwaysJids; 880 private final List<Jid> neverJids; 881 private final MamVersion mamVersion; 882 private DefaultBehavior defaultBehavior; 883 884 private MamPrefs(MamPrefsResult mamPrefsResult) { 885 MamPrefsIQ mamPrefsIq = mamPrefsResult.mamPrefs; 886 this.alwaysJids = new ArrayList<>(mamPrefsIq.getAlwaysJids()); 887 this.neverJids = new ArrayList<>(mamPrefsIq.getNeverJids()); 888 this.defaultBehavior = mamPrefsIq.getDefault(); 889 this.mamVersion = MamVersion.fromNamespace(mamPrefsIq.getNamespace()); 890 } 891 892 public void setDefaultBehavior(DefaultBehavior defaultBehavior) { 893 this.defaultBehavior = Objects.requireNonNull(defaultBehavior, "defaultBehavior must not be null"); 894 } 895 896 public DefaultBehavior getDefaultBehavior() { 897 return defaultBehavior; 898 } 899 900 public List<Jid> getAlwaysJids() { 901 return alwaysJids; 902 } 903 904 public List<Jid> getNeverJids() { 905 return neverJids; 906 } 907 908 private MamPrefsIQ constructMamPrefsIq() { 909 return mamVersion.newElementFactory().newPrefsIQ(alwaysJids, neverJids, defaultBehavior); 910 } 911 } 912 913 private MamPrefsResult queryMamPrefs(MamPrefsIQ mamPrefsIQ) throws NoResponseException, XMPPErrorException, 914 NotConnectedException, InterruptedException, NotLoggedInException { 915 final XMPPConnection connection = getAuthenticatedConnectionOrThrow(); 916 917 MamPrefsIQ mamPrefsResultIQ = connection.sendIqRequestAndWaitForResponse(mamPrefsIQ); 918 919 return new MamPrefsResult(mamPrefsResultIQ, DataForm.from(mamPrefsIQ)); 920 } 921 922}