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}