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