1package eu.siacs.conversations.services;
2
3import android.util.Log;
4
5import java.math.BigInteger;
6import java.util.ArrayList;
7import java.util.HashSet;
8import java.util.Iterator;
9import java.util.List;
10
11import eu.siacs.conversations.Config;
12import eu.siacs.conversations.R;
13import eu.siacs.conversations.entities.Account;
14import eu.siacs.conversations.entities.Conversation;
15import eu.siacs.conversations.entities.Conversational;
16import eu.siacs.conversations.entities.ReceiptRequest;
17import eu.siacs.conversations.generator.AbstractGenerator;
18import eu.siacs.conversations.xml.Element;
19import eu.siacs.conversations.xmpp.OnAdvancedStreamFeaturesLoaded;
20import eu.siacs.conversations.xmpp.mam.MamReference;
21import eu.siacs.conversations.xmpp.stanzas.IqPacket;
22import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
23import eu.siacs.conversations.xmpp.Jid;
24
25public class MessageArchiveService implements OnAdvancedStreamFeaturesLoaded {
26
27 private final XmppConnectionService mXmppConnectionService;
28
29 private final HashSet<Query> queries = new HashSet<>();
30 private final ArrayList<Query> pendingQueries = new ArrayList<>();
31
32 public enum Version {
33 MAM_0("urn:xmpp:mam:0", true),
34 MAM_1("urn:xmpp:mam:1", false),
35 MAM_2("urn:xmpp:mam:2", false);
36
37 public final boolean legacy;
38 public final String namespace;
39
40 Version(String namespace, boolean legacy) {
41 this.namespace = namespace;
42 this.legacy = legacy;
43 }
44
45 public static Version get(Account account) {
46 return get(account,null);
47 }
48
49 public static Version get(Account account, Conversation conversation) {
50 if (conversation == null || conversation.getMode() == Conversation.MODE_SINGLE) {
51 return get(account.getXmppConnection().getFeatures().getAccountFeatures());
52 } else {
53 return get(conversation.getMucOptions().getFeatures());
54 }
55 }
56
57 private static Version get(List<String> features) {
58 final Version[] values = values();
59 for(int i = values.length -1; i >= 0; --i) {
60 for(String feature : features) {
61 if (values[i].namespace.equals(feature)) {
62 return values[i];
63 }
64 }
65 }
66 return MAM_0;
67 }
68
69 public static boolean has(List<String> features) {
70 for(String feature : features) {
71 for(Version version : values()) {
72 if (version.namespace.equals(feature)) {
73 return true;
74 }
75 }
76 }
77 return false;
78 }
79
80 public static Element findResult(MessagePacket packet) {
81 for(Version version : values()) {
82 Element result = packet.findChild("result", version.namespace);
83 if (result != null) {
84 return result;
85 }
86 }
87 return null;
88 }
89
90 };
91
92 MessageArchiveService(final XmppConnectionService service) {
93 this.mXmppConnectionService = service;
94 }
95
96 private void catchup(final Account account) {
97 synchronized (this.queries) {
98 for (Iterator<Query> iterator = this.queries.iterator(); iterator.hasNext(); ) {
99 Query query = iterator.next();
100 if (query.getAccount() == account) {
101 iterator.remove();
102 }
103 }
104 }
105 MamReference mamReference = MamReference.max(
106 mXmppConnectionService.databaseBackend.getLastMessageReceived(account),
107 mXmppConnectionService.databaseBackend.getLastClearDate(account)
108 );
109 mamReference = MamReference.max(mamReference, mXmppConnectionService.getAutomaticMessageDeletionDate());
110 long endCatchup = account.getXmppConnection().getLastSessionEstablished();
111 final Query query;
112 if (mamReference.getTimestamp() == 0) {
113 return;
114 } else if (endCatchup - mamReference.getTimestamp() >= Config.MAM_MAX_CATCHUP) {
115 long startCatchup = endCatchup - Config.MAM_MAX_CATCHUP;
116 List<Conversation> conversations = mXmppConnectionService.getConversations();
117 for (Conversation conversation : conversations) {
118 if (conversation.getMode() == Conversation.MODE_SINGLE && conversation.getAccount() == account && startCatchup > conversation.getLastMessageTransmitted().getTimestamp()) {
119 this.query(conversation, startCatchup, true);
120 }
121 }
122 query = new Query(account, new MamReference(startCatchup), 0);
123 } else {
124 query = new Query(account, mamReference, 0);
125 }
126 synchronized (this.queries) {
127 this.queries.add(query);
128 }
129 this.execute(query);
130 }
131
132 void catchupMUC(final Conversation conversation) {
133 if (conversation.getLastMessageTransmitted().getTimestamp() < 0 && conversation.countMessages() == 0) {
134 query(conversation,
135 new MamReference(0),
136 0,
137 true);
138 } else {
139 query(conversation,
140 conversation.getLastMessageTransmitted(),
141 0,
142 true);
143 }
144 }
145
146 public Query query(final Conversation conversation) {
147 if (conversation.getLastMessageTransmitted().getTimestamp() < 0 && conversation.countMessages() == 0) {
148 return query(conversation,
149 new MamReference(0),
150 System.currentTimeMillis(),
151 false);
152 } else {
153 return query(conversation,
154 conversation.getLastMessageTransmitted(),
155 conversation.getAccount().getXmppConnection().getLastSessionEstablished(),
156 false);
157 }
158 }
159
160 public boolean isCatchingUp(Conversation conversation) {
161 final Account account = conversation.getAccount();
162 if (account.getXmppConnection().isWaitingForSmCatchup()) {
163 return true;
164 } else {
165 synchronized (this.queries) {
166 for (Query query : this.queries) {
167 if (query.getAccount() == account && query.isCatchup() && ((conversation.getMode() == Conversation.MODE_SINGLE && query.getWith() == null) || query.getConversation() == conversation)) {
168 return true;
169 }
170 }
171 }
172 return false;
173 }
174 }
175
176 public Query query(final Conversation conversation, long end, boolean allowCatchup) {
177 return this.query(conversation, conversation.getLastMessageTransmitted(), end, allowCatchup);
178 }
179
180 public Query query(Conversation conversation, MamReference start, long end, boolean allowCatchup) {
181 synchronized (this.queries) {
182 final Query query;
183 final MamReference startActual = MamReference.max(start, mXmppConnectionService.getAutomaticMessageDeletionDate());
184 if (start.getTimestamp() == 0) {
185 query = new Query(conversation, startActual, end, false);
186 query.reference = conversation.getFirstMamReference();
187 } else {
188 if (allowCatchup) {
189 MamReference maxCatchup = MamReference.max(startActual, System.currentTimeMillis() - Config.MAM_MAX_CATCHUP);
190 if (maxCatchup.greaterThan(startActual)) {
191 Query reverseCatchup = new Query(conversation, startActual, maxCatchup.getTimestamp(), false);
192 this.queries.add(reverseCatchup);
193 this.execute(reverseCatchup);
194 }
195 query = new Query(conversation, maxCatchup, end, true);
196 } else {
197 query = new Query(conversation, startActual, end, false);
198 }
199 }
200 if (end != 0 && start.greaterThan(end)) {
201 return null;
202 }
203 this.queries.add(query);
204 this.execute(query);
205 return query;
206 }
207 }
208
209 void executePendingQueries(final Account account) {
210 List<Query> pending = new ArrayList<>();
211 synchronized (this.pendingQueries) {
212 for (Iterator<Query> iterator = this.pendingQueries.iterator(); iterator.hasNext(); ) {
213 Query query = iterator.next();
214 if (query.getAccount() == account) {
215 pending.add(query);
216 iterator.remove();
217 }
218 }
219 }
220 for (Query query : pending) {
221 this.execute(query);
222 }
223 }
224
225 private void execute(final Query query) {
226 final Account account = query.getAccount();
227 if (account.getStatus() == Account.State.ONLINE) {
228 final Conversation conversation = query.getConversation();
229 if (conversation != null && conversation.getStatus() == Conversation.STATUS_ARCHIVED) {
230 throw new IllegalStateException("Attempted to run MAM query for archived conversation");
231 }
232 Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": running mam query " + query.toString());
233 final IqPacket packet = this.mXmppConnectionService.getIqGenerator().queryMessageArchiveManagement(query);
234 this.mXmppConnectionService.sendIqPacket(account, packet, (a, p) -> {
235 final Element fin = p.findChild("fin", query.version.namespace);
236 if (p.getType() == IqPacket.TYPE.TIMEOUT) {
237 synchronized (this.queries) {
238 this.queries.remove(query);
239 if (query.hasCallback()) {
240 query.callback(false);
241 }
242 }
243 } else if (p.getType() == IqPacket.TYPE.RESULT && fin != null) {
244 final boolean running;
245 synchronized (this.queries) {
246 running = this.queries.contains(query);
247 }
248 if (running) {
249 processFin(query, fin);
250 } else {
251 Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignoring MAM iq result because query had been killed");
252 }
253 } else if (p.getType() == IqPacket.TYPE.RESULT && query.isLegacy()) {
254 //do nothing
255 } else {
256 Log.d(Config.LOGTAG, a.getJid().asBareJid().toString() + ": error executing mam: " + p.toString());
257 finalizeQuery(query, true);
258 }
259 });
260 } else {
261 synchronized (this.pendingQueries) {
262 this.pendingQueries.add(query);
263 }
264 }
265 }
266
267 private void finalizeQuery(final Query query, boolean done) {
268 synchronized (this.queries) {
269 if (!this.queries.remove(query)) {
270 throw new IllegalStateException("Unable to remove query from queries");
271 }
272 }
273 final Conversation conversation = query.getConversation();
274 if (conversation != null) {
275 conversation.sort();
276 conversation.setHasMessagesLeftOnServer(!done);
277 } else {
278 for (Conversation tmp : this.mXmppConnectionService.getConversations()) {
279 if (tmp.getAccount() == query.getAccount()) {
280 tmp.sort();
281 }
282 }
283 }
284 if (query.hasCallback()) {
285 query.callback(done);
286 } else {
287 this.mXmppConnectionService.updateConversationUi();
288 }
289 }
290
291 boolean inCatchup(Account account) {
292 synchronized (this.queries) {
293 for (Query query : queries) {
294 if (query.account == account && query.isCatchup() && query.getWith() == null) {
295 return true;
296 }
297 }
298 }
299 return false;
300 }
301
302 public boolean isCatchupInProgress(Conversation conversation) {
303 synchronized (this.queries) {
304 for(Query query : queries) {
305 if (query.account == conversation.getAccount() && query.isCatchup()) {
306 final Jid with = query.getWith() == null ? null : query.getWith().asBareJid();
307 if ((conversation.getMode() == Conversational.MODE_SINGLE && with == null) || (conversation.getJid().asBareJid().equals(with))) {
308 return true;
309 }
310 }
311 }
312 }
313 return false;
314 }
315
316 boolean queryInProgress(Conversation conversation, XmppConnectionService.OnMoreMessagesLoaded callback) {
317 synchronized (this.queries) {
318 for (Query query : queries) {
319 if (query.conversation == conversation) {
320 if (!query.hasCallback() && callback != null) {
321 query.setCallback(callback);
322 }
323 return true;
324 }
325 }
326 return false;
327 }
328 }
329
330 public boolean queryInProgress(Conversation conversation) {
331 return queryInProgress(conversation, null);
332 }
333
334 public void processFinLegacy(Element fin, Jid from) {
335 Query query = findQuery(fin.getAttribute("queryid"));
336 if (query != null && query.validFrom(from)) {
337 processFin(query, fin);
338 }
339 }
340
341 private void processFin(Query query, Element fin) {
342 boolean complete = fin.getAttributeAsBoolean("complete");
343 Element set = fin.findChild("set", "http://jabber.org/protocol/rsm");
344 Element last = set == null ? null : set.findChild("last");
345 String count = set == null ? null : set.findChildContent("count");
346 Element first = set == null ? null : set.findChild("first");
347 Element relevant = query.getPagingOrder() == PagingOrder.NORMAL ? last : first;
348 boolean abort = (!query.isCatchup() && query.getTotalCount() >= Config.PAGE_SIZE) || query.getTotalCount() >= Config.MAM_MAX_MESSAGES;
349 if (query.getConversation() != null) {
350 query.getConversation().setFirstMamReference(first == null ? null : first.getContent());
351 }
352 if (complete || relevant == null || abort) {
353 //TODO: FIX done logic to look at complete. using count is probably unreliable because it can be ommited and doesn’t work with paging.
354 boolean done;
355 if (query.isCatchup()) {
356 done = false;
357 } else {
358 if (count != null) {
359 try {
360 done = Integer.parseInt(count) <= query.getTotalCount();
361 } catch (NumberFormatException e) {
362 done = false;
363 }
364 } else {
365 done = query.getTotalCount() == 0;
366 }
367 }
368 done = done || (query.getActualMessageCount() == 0 && !query.isCatchup());
369 this.finalizeQuery(query, done);
370
371 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": finished mam after " + query.getTotalCount() + "(" + query.getActualMessageCount() + ") messages. messages left=" + Boolean.toString(!done) + " count=" + count);
372 if (query.isCatchup() && query.getActualMessageCount() > 0) {
373 mXmppConnectionService.getNotificationService().finishBacklog(true, query.getAccount());
374 }
375 processPostponed(query);
376 } else {
377 final Query nextQuery;
378 if (query.getPagingOrder() == PagingOrder.NORMAL) {
379 nextQuery = query.next(last == null ? null : last.getContent());
380 } else {
381 nextQuery = query.prev(first == null ? null : first.getContent());
382 }
383 this.execute(nextQuery);
384 this.finalizeQuery(query, false);
385 synchronized (this.queries) {
386 this.queries.add(nextQuery);
387 }
388 }
389 }
390
391 void kill(Conversation conversation) {
392 final ArrayList<Query> toBeKilled = new ArrayList<>();
393 synchronized (this.queries) {
394 for (final Query q : queries) {
395 if (q.conversation == conversation) {
396 toBeKilled.add(q);
397 }
398 }
399 }
400 for (Query q : toBeKilled) {
401 kill(q);
402 }
403 }
404
405 private void kill(Query query) {
406 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": killing mam query prematurely");
407 query.callback = null;
408 this.finalizeQuery(query, false);
409 if (query.isCatchup() && query.getActualMessageCount() > 0) {
410 mXmppConnectionService.getNotificationService().finishBacklog(true, query.getAccount());
411 }
412 this.processPostponed(query);
413 }
414
415 private void processPostponed(Query query) {
416 query.account.getAxolotlService().processPostponed();
417 query.pendingReceiptRequests.removeAll(query.receiptRequests);
418 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": found " + query.pendingReceiptRequests.size() + " pending receipt requests");
419 Iterator<ReceiptRequest> iterator = query.pendingReceiptRequests.iterator();
420 while (iterator.hasNext()) {
421 ReceiptRequest rr = iterator.next();
422 mXmppConnectionService.sendMessagePacket(query.account, mXmppConnectionService.getMessageGenerator().received(query.account, rr.getJid(), rr.getId()));
423 iterator.remove();
424 }
425 }
426
427 public Query findQuery(String id) {
428 if (id == null) {
429 return null;
430 }
431 synchronized (this.queries) {
432 for (Query query : this.queries) {
433 if (query.getQueryId().equals(id)) {
434 return query;
435 }
436 }
437 return null;
438 }
439 }
440
441 @Override
442 public void onAdvancedStreamFeaturesAvailable(Account account) {
443 if (account.getXmppConnection() != null && account.getXmppConnection().getFeatures().mam()) {
444 this.catchup(account);
445 }
446 }
447
448 public enum PagingOrder {
449 NORMAL,
450 REVERSE
451 }
452
453 public class Query {
454 private HashSet<ReceiptRequest> pendingReceiptRequests = new HashSet<>();
455 private HashSet<ReceiptRequest> receiptRequests = new HashSet<>();
456 private int totalCount = 0;
457 private int actualCount = 0;
458 private int actualInThisQuery = 0;
459 private long start;
460 private long end;
461 private String queryId;
462 private String reference = null;
463 private Account account;
464 private Conversation conversation;
465 private PagingOrder pagingOrder = PagingOrder.NORMAL;
466 private XmppConnectionService.OnMoreMessagesLoaded callback = null;
467 private boolean catchup = true;
468 public final Version version;
469
470
471 Query(Conversation conversation, MamReference start, long end, boolean catchup) {
472 this(conversation.getAccount(), Version.get(conversation.getAccount(), conversation), catchup ? start : start.timeOnly(), end);
473 this.conversation = conversation;
474 this.pagingOrder = catchup ? PagingOrder.NORMAL : PagingOrder.REVERSE;
475 this.catchup = catchup;
476 }
477
478 Query(Account account, MamReference start, long end) {
479 this(account, Version.get(account), start, end);
480 }
481
482 Query(Account account, Version version, MamReference start, long end) {
483 this.account = account;
484 if (start.getReference() != null) {
485 this.reference = start.getReference();
486 } else {
487 this.start = start.getTimestamp();
488 }
489 this.end = end;
490 this.queryId = new BigInteger(50, mXmppConnectionService.getRNG()).toString(32);
491 this.version = version;
492 }
493
494 private Query page(String reference) {
495 Query query = new Query(this.account, this.version, new MamReference(this.start, reference), this.end);
496 query.conversation = conversation;
497 query.totalCount = totalCount;
498 query.actualCount = actualCount;
499 query.pendingReceiptRequests = pendingReceiptRequests;
500 query.receiptRequests = receiptRequests;
501 query.callback = callback;
502 query.catchup = catchup;
503 return query;
504 }
505
506 public void removePendingReceiptRequest(ReceiptRequest receiptRequest) {
507 if (!this.pendingReceiptRequests.remove(receiptRequest)) {
508 this.receiptRequests.add(receiptRequest);
509 }
510 }
511
512 public void addPendingReceiptRequest(ReceiptRequest receiptRequest) {
513 this.pendingReceiptRequests.add(receiptRequest);
514 }
515
516 public boolean isLegacy() {
517 return version.legacy;
518 }
519
520 public boolean safeToExtractTrueCounterpart() {
521 return muc() && !isLegacy();
522 }
523
524 public Query next(String reference) {
525 Query query = page(reference);
526 query.pagingOrder = PagingOrder.NORMAL;
527 return query;
528 }
529
530 Query prev(String reference) {
531 Query query = page(reference);
532 query.pagingOrder = PagingOrder.REVERSE;
533 return query;
534 }
535
536 public String getReference() {
537 return reference;
538 }
539
540 public PagingOrder getPagingOrder() {
541 return this.pagingOrder;
542 }
543
544 public String getQueryId() {
545 return queryId;
546 }
547
548 public Jid getWith() {
549 return conversation == null ? null : conversation.getJid().asBareJid();
550 }
551
552 public boolean muc() {
553 return conversation != null && conversation.getMode() == Conversation.MODE_MULTI;
554 }
555
556 public long getStart() {
557 return start;
558 }
559
560 public boolean isCatchup() {
561 return catchup;
562 }
563
564 public void setCallback(XmppConnectionService.OnMoreMessagesLoaded callback) {
565 this.callback = callback;
566 }
567
568 public void callback(boolean done) {
569 if (this.callback != null) {
570 this.callback.onMoreMessagesLoaded(actualCount, conversation);
571 if (done) {
572 this.callback.informUser(R.string.no_more_history_on_server);
573 }
574 }
575 }
576
577 public long getEnd() {
578 return end;
579 }
580
581 public Conversation getConversation() {
582 return conversation;
583 }
584
585 public Account getAccount() {
586 return this.account;
587 }
588
589 public void incrementMessageCount() {
590 this.totalCount++;
591 }
592
593 public void incrementActualMessageCount() {
594 this.actualInThisQuery++;
595 this.actualCount++;
596 }
597
598 int getTotalCount() {
599 return this.totalCount;
600 }
601
602 int getActualMessageCount() {
603 return this.actualCount;
604 }
605
606 public int getActualInThisQuery() {
607 return this.actualInThisQuery;
608 }
609
610 public boolean validFrom(Jid from) {
611 if (muc()) {
612 return getWith().equals(from);
613 } else {
614 return (from == null) || account.getJid().asBareJid().equals(from.asBareJid());
615 }
616 }
617
618 @Override
619 public String toString() {
620 StringBuilder builder = new StringBuilder();
621 if (this.muc()) {
622 builder.append("to=");
623 builder.append(this.getWith().toString());
624 } else {
625 builder.append("with=");
626 if (this.getWith() == null) {
627 builder.append("*");
628 } else {
629 builder.append(getWith().toString());
630 }
631 }
632 if (this.start != 0) {
633 builder.append(", start=");
634 builder.append(AbstractGenerator.getTimestamp(this.start));
635 }
636 if (this.end != 0) {
637 builder.append(", end=");
638 builder.append(AbstractGenerator.getTimestamp(this.end));
639 }
640 builder.append(", order=").append(pagingOrder.toString());
641 if (this.reference != null) {
642 if (this.pagingOrder == PagingOrder.NORMAL) {
643 builder.append(", after=");
644 } else {
645 builder.append(", before=");
646 }
647 builder.append(this.reference);
648 }
649 builder.append(", catchup=").append(Boolean.toString(catchup));
650 builder.append(", ns=").append(version.namespace);
651 return builder.toString();
652 }
653
654 boolean hasCallback() {
655 return this.callback != null;
656 }
657 }
658}