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 Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": running mam query " + query.toString());
229 IqPacket packet = this.mXmppConnectionService.getIqGenerator().queryMessageArchiveManagement(query);
230 this.mXmppConnectionService.sendIqPacket(account, packet, (a, p) -> {
231 Element fin = p.findChild("fin", query.version.namespace);
232 if (p.getType() == IqPacket.TYPE.TIMEOUT) {
233 synchronized (MessageArchiveService.this.queries) {
234 MessageArchiveService.this.queries.remove(query);
235 if (query.hasCallback()) {
236 query.callback(false);
237 }
238 }
239 } else if (p.getType() == IqPacket.TYPE.RESULT && fin != null) {
240 processFin(query, fin);
241 } else if (p.getType() == IqPacket.TYPE.RESULT && query.isLegacy()) {
242 //do nothing
243 } else {
244 Log.d(Config.LOGTAG, a.getJid().asBareJid().toString() + ": error executing mam: " + p.toString());
245 finalizeQuery(query, true);
246 }
247 });
248 } else {
249 synchronized (this.pendingQueries) {
250 this.pendingQueries.add(query);
251 }
252 }
253 }
254
255 private void finalizeQuery(Query query, boolean done) {
256 synchronized (this.queries) {
257 this.queries.remove(query);
258 }
259 final Conversation conversation = query.getConversation();
260 if (conversation != null) {
261 conversation.sort();
262 conversation.setHasMessagesLeftOnServer(!done);
263 } else {
264 for (Conversation tmp : this.mXmppConnectionService.getConversations()) {
265 if (tmp.getAccount() == query.getAccount()) {
266 tmp.sort();
267 }
268 }
269 }
270 if (query.hasCallback()) {
271 query.callback(done);
272 } else {
273 this.mXmppConnectionService.updateConversationUi();
274 }
275 }
276
277 boolean inCatchup(Account account) {
278 synchronized (this.queries) {
279 for (Query query : queries) {
280 if (query.account == account && query.isCatchup() && query.getWith() == null) {
281 return true;
282 }
283 }
284 }
285 return false;
286 }
287
288 public boolean isCatchupInProgress(Conversation conversation) {
289 synchronized (this.queries) {
290 for(Query query : queries) {
291 if (query.account == conversation.getAccount() && query.isCatchup()) {
292 final Jid with = query.getWith() == null ? null : query.getWith().asBareJid();
293 if ((conversation.getMode() == Conversational.MODE_SINGLE && with == null) || (conversation.getJid().asBareJid().equals(with))) {
294 return true;
295 }
296 }
297 }
298 }
299 return false;
300 }
301
302 boolean queryInProgress(Conversation conversation, XmppConnectionService.OnMoreMessagesLoaded callback) {
303 synchronized (this.queries) {
304 for (Query query : queries) {
305 if (query.conversation == conversation) {
306 if (!query.hasCallback() && callback != null) {
307 query.setCallback(callback);
308 }
309 return true;
310 }
311 }
312 return false;
313 }
314 }
315
316 public boolean queryInProgress(Conversation conversation) {
317 return queryInProgress(conversation, null);
318 }
319
320 public void processFinLegacy(Element fin, Jid from) {
321 Query query = findQuery(fin.getAttribute("queryid"));
322 if (query != null && query.validFrom(from)) {
323 processFin(query, fin);
324 }
325 }
326
327 private void processFin(Query query, Element fin) {
328 boolean complete = fin.getAttributeAsBoolean("complete");
329 Element set = fin.findChild("set", "http://jabber.org/protocol/rsm");
330 Element last = set == null ? null : set.findChild("last");
331 String count = set == null ? null : set.findChildContent("count");
332 Element first = set == null ? null : set.findChild("first");
333 Element relevant = query.getPagingOrder() == PagingOrder.NORMAL ? last : first;
334 boolean abort = (!query.isCatchup() && query.getTotalCount() >= Config.PAGE_SIZE) || query.getTotalCount() >= Config.MAM_MAX_MESSAGES;
335 if (query.getConversation() != null) {
336 query.getConversation().setFirstMamReference(first == null ? null : first.getContent());
337 }
338 if (complete || relevant == null || abort) {
339 //TODO: FIX done logic to look at complete. using count is probably unreliable because it can be ommited and doesn’t work with paging.
340 boolean done;
341 if (query.isCatchup()) {
342 done = false;
343 } else {
344 if (count != null) {
345 try {
346 done = Integer.parseInt(count) <= query.getTotalCount();
347 } catch (NumberFormatException e) {
348 done = false;
349 }
350 } else {
351 done = query.getTotalCount() == 0;
352 }
353 }
354 done = done || (query.getActualMessageCount() == 0 && !query.isCatchup());
355 this.finalizeQuery(query, done);
356
357 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": finished mam after " + query.getTotalCount() + "(" + query.getActualMessageCount() + ") messages. messages left=" + Boolean.toString(!done) + " count=" + count);
358 if (query.isCatchup() && query.getActualMessageCount() > 0) {
359 mXmppConnectionService.getNotificationService().finishBacklog(true, query.getAccount());
360 }
361 processPostponed(query);
362 } else {
363 final Query nextQuery;
364 if (query.getPagingOrder() == PagingOrder.NORMAL) {
365 nextQuery = query.next(last == null ? null : last.getContent());
366 } else {
367 nextQuery = query.prev(first == null ? null : first.getContent());
368 }
369 this.execute(nextQuery);
370 this.finalizeQuery(query, false);
371 synchronized (this.queries) {
372 this.queries.add(nextQuery);
373 }
374 }
375 }
376
377 void kill(Conversation conversation) {
378 final ArrayList<Query> toBeKilled = new ArrayList<>();
379 synchronized (this.queries) {
380 for (Query q : queries) {
381 if (q.conversation == conversation) {
382 toBeKilled.add(q);
383 }
384 }
385 }
386 for (Query q : toBeKilled) {
387 kill(q);
388 }
389 }
390
391 private void kill(Query query) {
392 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": killing mam query prematurely");
393 query.callback = null;
394 this.finalizeQuery(query, false);
395 if (query.isCatchup() && query.getActualMessageCount() > 0) {
396 mXmppConnectionService.getNotificationService().finishBacklog(true, query.getAccount());
397 }
398 this.processPostponed(query);
399 }
400
401 private void processPostponed(Query query) {
402 query.account.getAxolotlService().processPostponed();
403 query.pendingReceiptRequests.removeAll(query.receiptRequests);
404 Log.d(Config.LOGTAG, query.getAccount().getJid().asBareJid() + ": found " + query.pendingReceiptRequests.size() + " pending receipt requests");
405 Iterator<ReceiptRequest> iterator = query.pendingReceiptRequests.iterator();
406 while (iterator.hasNext()) {
407 ReceiptRequest rr = iterator.next();
408 mXmppConnectionService.sendMessagePacket(query.account, mXmppConnectionService.getMessageGenerator().received(query.account, rr.getJid(), rr.getId()));
409 iterator.remove();
410 }
411 }
412
413 public Query findQuery(String id) {
414 if (id == null) {
415 return null;
416 }
417 synchronized (this.queries) {
418 for (Query query : this.queries) {
419 if (query.getQueryId().equals(id)) {
420 return query;
421 }
422 }
423 return null;
424 }
425 }
426
427 @Override
428 public void onAdvancedStreamFeaturesAvailable(Account account) {
429 if (account.getXmppConnection() != null && account.getXmppConnection().getFeatures().mam()) {
430 this.catchup(account);
431 }
432 }
433
434 public enum PagingOrder {
435 NORMAL,
436 REVERSE
437 }
438
439 public class Query {
440 private HashSet<ReceiptRequest> pendingReceiptRequests = new HashSet<>();
441 private HashSet<ReceiptRequest> receiptRequests = new HashSet<>();
442 private int totalCount = 0;
443 private int actualCount = 0;
444 private int actualInThisQuery = 0;
445 private long start;
446 private long end;
447 private String queryId;
448 private String reference = null;
449 private Account account;
450 private Conversation conversation;
451 private PagingOrder pagingOrder = PagingOrder.NORMAL;
452 private XmppConnectionService.OnMoreMessagesLoaded callback = null;
453 private boolean catchup = true;
454 public final Version version;
455
456
457 Query(Conversation conversation, MamReference start, long end, boolean catchup) {
458 this(conversation.getAccount(), Version.get(conversation.getAccount(), conversation), catchup ? start : start.timeOnly(), end);
459 this.conversation = conversation;
460 this.pagingOrder = catchup ? PagingOrder.NORMAL : PagingOrder.REVERSE;
461 this.catchup = catchup;
462 }
463
464 Query(Account account, MamReference start, long end) {
465 this(account, Version.get(account), start, end);
466 }
467
468 Query(Account account, Version version, MamReference start, long end) {
469 this.account = account;
470 if (start.getReference() != null) {
471 this.reference = start.getReference();
472 } else {
473 this.start = start.getTimestamp();
474 }
475 this.end = end;
476 this.queryId = new BigInteger(50, mXmppConnectionService.getRNG()).toString(32);
477 this.version = version;
478 }
479
480 private Query page(String reference) {
481 Query query = new Query(this.account, this.version, new MamReference(this.start, reference), this.end);
482 query.conversation = conversation;
483 query.totalCount = totalCount;
484 query.actualCount = actualCount;
485 query.pendingReceiptRequests = pendingReceiptRequests;
486 query.receiptRequests = receiptRequests;
487 query.callback = callback;
488 query.catchup = catchup;
489 return query;
490 }
491
492 public void removePendingReceiptRequest(ReceiptRequest receiptRequest) {
493 if (!this.pendingReceiptRequests.remove(receiptRequest)) {
494 this.receiptRequests.add(receiptRequest);
495 }
496 }
497
498 public void addPendingReceiptRequest(ReceiptRequest receiptRequest) {
499 this.pendingReceiptRequests.add(receiptRequest);
500 }
501
502 public boolean isLegacy() {
503 return version.legacy;
504 }
505
506 public boolean safeToExtractTrueCounterpart() {
507 return muc() && !isLegacy();
508 }
509
510 public Query next(String reference) {
511 Query query = page(reference);
512 query.pagingOrder = PagingOrder.NORMAL;
513 return query;
514 }
515
516 Query prev(String reference) {
517 Query query = page(reference);
518 query.pagingOrder = PagingOrder.REVERSE;
519 return query;
520 }
521
522 public String getReference() {
523 return reference;
524 }
525
526 public PagingOrder getPagingOrder() {
527 return this.pagingOrder;
528 }
529
530 public String getQueryId() {
531 return queryId;
532 }
533
534 public Jid getWith() {
535 return conversation == null ? null : conversation.getJid().asBareJid();
536 }
537
538 public boolean muc() {
539 return conversation != null && conversation.getMode() == Conversation.MODE_MULTI;
540 }
541
542 public long getStart() {
543 return start;
544 }
545
546 public boolean isCatchup() {
547 return catchup;
548 }
549
550 public void setCallback(XmppConnectionService.OnMoreMessagesLoaded callback) {
551 this.callback = callback;
552 }
553
554 public void callback(boolean done) {
555 if (this.callback != null) {
556 this.callback.onMoreMessagesLoaded(actualCount, conversation);
557 if (done) {
558 this.callback.informUser(R.string.no_more_history_on_server);
559 }
560 }
561 }
562
563 public long getEnd() {
564 return end;
565 }
566
567 public Conversation getConversation() {
568 return conversation;
569 }
570
571 public Account getAccount() {
572 return this.account;
573 }
574
575 public void incrementMessageCount() {
576 this.totalCount++;
577 }
578
579 public void incrementActualMessageCount() {
580 this.actualInThisQuery++;
581 this.actualCount++;
582 }
583
584 int getTotalCount() {
585 return this.totalCount;
586 }
587
588 int getActualMessageCount() {
589 return this.actualCount;
590 }
591
592 public int getActualInThisQuery() {
593 return this.actualInThisQuery;
594 }
595
596 public boolean validFrom(Jid from) {
597 if (muc()) {
598 return getWith().equals(from);
599 } else {
600 return (from == null) || account.getJid().asBareJid().equals(from.asBareJid());
601 }
602 }
603
604 @Override
605 public String toString() {
606 StringBuilder builder = new StringBuilder();
607 if (this.muc()) {
608 builder.append("to=");
609 builder.append(this.getWith().toString());
610 } else {
611 builder.append("with=");
612 if (this.getWith() == null) {
613 builder.append("*");
614 } else {
615 builder.append(getWith().toString());
616 }
617 }
618 if (this.start != 0) {
619 builder.append(", start=");
620 builder.append(AbstractGenerator.getTimestamp(this.start));
621 }
622 if (this.end != 0) {
623 builder.append(", end=");
624 builder.append(AbstractGenerator.getTimestamp(this.end));
625 }
626 builder.append(", order=").append(pagingOrder.toString());
627 if (this.reference != null) {
628 if (this.pagingOrder == PagingOrder.NORMAL) {
629 builder.append(", after=");
630 } else {
631 builder.append(", before=");
632 }
633 builder.append(this.reference);
634 }
635 builder.append(", catchup=").append(Boolean.toString(catchup));
636 builder.append(", ns=").append(version.namespace);
637 return builder.toString();
638 }
639
640 boolean hasCallback() {
641 return this.callback != null;
642 }
643 }
644}