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