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}