1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.database.Cursor;
5import android.graphics.drawable.Drawable;
6import android.graphics.Color;
7import android.os.Build;
8import android.text.Html;
9import android.text.SpannableStringBuilder;
10import android.util.Log;
11
12import com.cheogram.android.BobTransfer;
13import com.cheogram.android.GetThumbnailForCid;
14
15import com.google.common.io.ByteSource;
16import com.google.common.base.Strings;
17import com.google.common.collect.ImmutableSet;
18import com.google.common.primitives.Longs;
19
20import org.json.JSONException;
21
22import java.lang.ref.WeakReference;
23import java.io.IOException;
24import java.net.URI;
25import java.net.URISyntaxException;
26import java.util.ArrayList;
27import java.util.Arrays;
28import java.util.Iterator;
29import java.util.List;
30import java.util.Set;
31import java.util.stream.Collectors;
32import java.util.concurrent.CopyOnWriteArraySet;
33
34import io.ipfs.cid.Cid;
35
36import eu.siacs.conversations.Config;
37import eu.siacs.conversations.crypto.axolotl.AxolotlService;
38import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
39import eu.siacs.conversations.http.URL;
40import eu.siacs.conversations.services.AvatarService;
41import eu.siacs.conversations.ui.util.PresenceSelector;
42import eu.siacs.conversations.utils.CryptoHelper;
43import eu.siacs.conversations.utils.Emoticons;
44import eu.siacs.conversations.utils.GeoHelper;
45import eu.siacs.conversations.utils.MessageUtils;
46import eu.siacs.conversations.utils.MimeUtils;
47import eu.siacs.conversations.utils.UIHelper;
48import eu.siacs.conversations.xmpp.Jid;
49import eu.siacs.conversations.xml.Element;
50import eu.siacs.conversations.xml.Tag;
51import eu.siacs.conversations.xml.XmlReader;
52
53public class Message extends AbstractEntity implements AvatarService.Avatarable {
54
55 public static final String TABLENAME = "messages";
56
57 public static final int STATUS_RECEIVED = 0;
58 public static final int STATUS_UNSEND = 1;
59 public static final int STATUS_SEND = 2;
60 public static final int STATUS_SEND_FAILED = 3;
61 public static final int STATUS_WAITING = 5;
62 public static final int STATUS_OFFERED = 6;
63 public static final int STATUS_SEND_RECEIVED = 7;
64 public static final int STATUS_SEND_DISPLAYED = 8;
65
66 public static final int ENCRYPTION_NONE = 0;
67 public static final int ENCRYPTION_PGP = 1;
68 public static final int ENCRYPTION_OTR = 2;
69 public static final int ENCRYPTION_DECRYPTED = 3;
70 public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
71 public static final int ENCRYPTION_AXOLOTL = 5;
72 public static final int ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE = 6;
73 public static final int ENCRYPTION_AXOLOTL_FAILED = 7;
74
75 public static final int TYPE_TEXT = 0;
76 public static final int TYPE_IMAGE = 1;
77 public static final int TYPE_FILE = 2;
78 public static final int TYPE_STATUS = 3;
79 public static final int TYPE_PRIVATE = 4;
80 public static final int TYPE_PRIVATE_FILE = 5;
81 public static final int TYPE_RTP_SESSION = 6;
82
83 public static final String CONVERSATION = "conversationUuid";
84 public static final String COUNTERPART = "counterpart";
85 public static final String TRUE_COUNTERPART = "trueCounterpart";
86 public static final String BODY = "body";
87 public static final String BODY_LANGUAGE = "bodyLanguage";
88 public static final String TIME_SENT = "timeSent";
89 public static final String ENCRYPTION = "encryption";
90 public static final String STATUS = "status";
91 public static final String TYPE = "type";
92 public static final String CARBON = "carbon";
93 public static final String OOB = "oob";
94 public static final String EDITED = "edited";
95 public static final String REMOTE_MSG_ID = "remoteMsgId";
96 public static final String SERVER_MSG_ID = "serverMsgId";
97 public static final String RELATIVE_FILE_PATH = "relativeFilePath";
98 public static final String FINGERPRINT = "axolotl_fingerprint";
99 public static final String READ = "read";
100 public static final String ERROR_MESSAGE = "errorMsg";
101 public static final String READ_BY_MARKERS = "readByMarkers";
102 public static final String MARKABLE = "markable";
103 public static final String DELETED = "deleted";
104 public static final String ME_COMMAND = "/me ";
105
106 public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
107
108
109 public boolean markable = false;
110 protected String conversationUuid;
111 protected Jid counterpart;
112 protected Jid trueCounterpart;
113 protected String body;
114 protected String subject;
115 protected String encryptedBody;
116 protected long timeSent;
117 protected int encryption;
118 protected int status;
119 protected int type;
120 protected boolean deleted = false;
121 protected boolean carbon = false;
122 private boolean oob = false;
123 protected URI oobUri = null;
124 protected List<Element> payloads = new ArrayList<>();
125 protected List<Edit> edits = new ArrayList<>();
126 protected String relativeFilePath;
127 protected boolean read = true;
128 protected String remoteMsgId = null;
129 private String bodyLanguage = null;
130 protected String serverMsgId = null;
131 private final Conversational conversation;
132 protected Transferable transferable = null;
133 private Message mNextMessage = null;
134 private Message mPreviousMessage = null;
135 private String axolotlFingerprint = null;
136 private String errorMessage = null;
137 private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
138
139 private Boolean isGeoUri = null;
140 private Boolean isEmojisOnly = null;
141 private Boolean treatAsDownloadable = null;
142 private FileParams fileParams = null;
143 private List<MucOptions.User> counterparts;
144 private WeakReference<MucOptions.User> user;
145
146 protected Message(Conversational conversation) {
147 this.conversation = conversation;
148 }
149
150 public Message(Conversational conversation, String body, int encryption) {
151 this(conversation, body, encryption, STATUS_UNSEND);
152 }
153
154 public Message(Conversational conversation, String body, int encryption, int status) {
155 this(conversation, java.util.UUID.randomUUID().toString(),
156 conversation.getUuid(),
157 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
158 null,
159 body,
160 System.currentTimeMillis(),
161 encryption,
162 status,
163 TYPE_TEXT,
164 false,
165 null,
166 null,
167 null,
168 null,
169 true,
170 null,
171 false,
172 null,
173 null,
174 false,
175 false,
176 null,
177 null,
178 null,
179 null,
180 null);
181 }
182
183 public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
184 this(conversation, java.util.UUID.randomUUID().toString(),
185 conversation.getUuid(),
186 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
187 null,
188 null,
189 System.currentTimeMillis(),
190 Message.ENCRYPTION_NONE,
191 status,
192 type,
193 false,
194 remoteMsgId,
195 null,
196 null,
197 null,
198 true,
199 null,
200 false,
201 null,
202 null,
203 false,
204 false,
205 null,
206 null,
207 null,
208 null,
209 null);
210 }
211
212 protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
213 final Jid trueCounterpart, final String body, final long timeSent,
214 final int encryption, final int status, final int type, final boolean carbon,
215 final String remoteMsgId, final String relativeFilePath,
216 final String serverMsgId, final String fingerprint, final boolean read,
217 final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
218 final boolean markable, final boolean deleted, final String bodyLanguage, final String subject, final String oobUri, final String fileParams, final List<Element> payloads) {
219 this.conversation = conversation;
220 this.uuid = uuid;
221 this.conversationUuid = conversationUUid;
222 this.counterpart = counterpart;
223 this.trueCounterpart = trueCounterpart;
224 this.body = body == null ? "" : body;
225 this.timeSent = timeSent;
226 this.encryption = encryption;
227 this.status = status;
228 this.type = type;
229 this.carbon = carbon;
230 this.remoteMsgId = remoteMsgId;
231 this.relativeFilePath = relativeFilePath;
232 this.serverMsgId = serverMsgId;
233 this.axolotlFingerprint = fingerprint;
234 this.read = read;
235 this.edits = Edit.fromJson(edited);
236 setOob(oobUri);
237 this.oob = oob;
238 this.errorMessage = errorMessage;
239 this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
240 this.markable = markable;
241 this.deleted = deleted;
242 this.bodyLanguage = bodyLanguage;
243 this.subject = subject;
244 if (fileParams != null) this.fileParams = new FileParams(fileParams);
245 if (payloads != null) this.payloads = payloads;
246 }
247
248 public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
249 String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
250 List<Element> payloads = new ArrayList<>();
251 if (payloadsStr != null) {
252 final XmlReader xmlReader = new XmlReader();
253 xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
254 Tag tag;
255 while ((tag = xmlReader.readTag()) != null) {
256 payloads.add(xmlReader.readElement(tag));
257 }
258 }
259
260 return new Message(conversation,
261 cursor.getString(cursor.getColumnIndex(UUID)),
262 cursor.getString(cursor.getColumnIndex(CONVERSATION)),
263 fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
264 fromString(cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART))),
265 cursor.getString(cursor.getColumnIndex(BODY)),
266 cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
267 cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
268 cursor.getInt(cursor.getColumnIndex(STATUS)),
269 cursor.getInt(cursor.getColumnIndex(TYPE)),
270 cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
271 cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
272 cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
273 cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
274 cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
275 cursor.getInt(cursor.getColumnIndex(READ)) > 0,
276 cursor.getString(cursor.getColumnIndex(EDITED)),
277 cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
278 cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
279 ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))),
280 cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
281 cursor.getInt(cursor.getColumnIndex(DELETED)) > 0,
282 cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
283 cursor.getString(cursor.getColumnIndex("subject")),
284 cursor.getString(cursor.getColumnIndex("oobUri")),
285 cursor.getString(cursor.getColumnIndex("fileParams")),
286 payloads
287 );
288 }
289
290 private static Jid fromString(String value) {
291 try {
292 if (value != null) {
293 return Jid.of(value);
294 }
295 } catch (IllegalArgumentException e) {
296 return null;
297 }
298 return null;
299 }
300
301 public static Message createStatusMessage(Conversation conversation, String body) {
302 final Message message = new Message(conversation);
303 message.setType(Message.TYPE_STATUS);
304 message.setStatus(Message.STATUS_RECEIVED);
305 message.body = body;
306 return message;
307 }
308
309 public static Message createLoadMoreMessage(Conversation conversation) {
310 final Message message = new Message(conversation);
311 message.setType(Message.TYPE_STATUS);
312 message.body = "LOAD_MORE";
313 return message;
314 }
315
316 public ContentValues getCheogramContentValues() {
317 ContentValues values = new ContentValues();
318 values.put(UUID, uuid);
319 values.put("subject", subject);
320 values.put("oobUri", oobUri == null ? null : oobUri.toString());
321 values.put("fileParams", fileParams == null ? null : fileParams.toString());
322 values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
323 return values;
324 }
325
326 @Override
327 public ContentValues getContentValues() {
328 ContentValues values = new ContentValues();
329 values.put(UUID, uuid);
330 values.put(CONVERSATION, conversationUuid);
331 if (counterpart == null) {
332 values.putNull(COUNTERPART);
333 } else {
334 values.put(COUNTERPART, counterpart.toString());
335 }
336 if (trueCounterpart == null) {
337 values.putNull(TRUE_COUNTERPART);
338 } else {
339 values.put(TRUE_COUNTERPART, trueCounterpart.toString());
340 }
341 values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
342 values.put(TIME_SENT, timeSent);
343 values.put(ENCRYPTION, encryption);
344 values.put(STATUS, status);
345 values.put(TYPE, type);
346 values.put(CARBON, carbon ? 1 : 0);
347 values.put(REMOTE_MSG_ID, remoteMsgId);
348 values.put(RELATIVE_FILE_PATH, relativeFilePath);
349 values.put(SERVER_MSG_ID, serverMsgId);
350 values.put(FINGERPRINT, axolotlFingerprint);
351 values.put(READ, read ? 1 : 0);
352 try {
353 values.put(EDITED, Edit.toJson(edits));
354 } catch (JSONException e) {
355 Log.e(Config.LOGTAG, "error persisting json for edits", e);
356 }
357 values.put(OOB, oob ? 1 : 0);
358 values.put(ERROR_MESSAGE, errorMessage);
359 values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
360 values.put(MARKABLE, markable ? 1 : 0);
361 values.put(DELETED, deleted ? 1 : 0);
362 values.put(BODY_LANGUAGE, bodyLanguage);
363 return values;
364 }
365
366 public String getConversationUuid() {
367 return conversationUuid;
368 }
369
370 public Conversational getConversation() {
371 return this.conversation;
372 }
373
374 public Jid getCounterpart() {
375 return counterpart;
376 }
377
378 public void setCounterpart(final Jid counterpart) {
379 this.counterpart = counterpart;
380 }
381
382 public Contact getContact() {
383 if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
384 return this.conversation.getContact();
385 } else {
386 if (this.trueCounterpart == null) {
387 return null;
388 } else {
389 return this.conversation.getAccount().getRoster()
390 .getContactFromContactList(this.trueCounterpart);
391 }
392 }
393 }
394
395 public String getBody() {
396 if (oobUri != null) {
397 return body.replace(oobUri.toString(), "");
398 } else {
399 return body;
400 }
401 }
402
403 public synchronized void setBody(String body) {
404 if (body == null) {
405 throw new Error("You should not set the message body to null");
406 }
407 this.body = body;
408 this.isGeoUri = null;
409 this.isEmojisOnly = null;
410 this.treatAsDownloadable = null;
411 }
412
413 public String getSubject() {
414 return subject;
415 }
416
417 public synchronized void setSubject(String subject) {
418 this.subject = subject;
419 }
420
421 public void setMucUser(MucOptions.User user) {
422 this.user = new WeakReference<>(user);
423 }
424
425 public boolean sameMucUser(Message otherMessage) {
426 final MucOptions.User thisUser = this.user == null ? null : this.user.get();
427 final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
428 return thisUser != null && thisUser == otherUser;
429 }
430
431 public String getErrorMessage() {
432 return errorMessage;
433 }
434
435 public boolean setErrorMessage(String message) {
436 boolean changed = (message != null && !message.equals(errorMessage))
437 || (message == null && errorMessage != null);
438 this.errorMessage = message;
439 return changed;
440 }
441
442 public long getTimeSent() {
443 return timeSent;
444 }
445
446 public int getEncryption() {
447 return encryption;
448 }
449
450 public void setEncryption(int encryption) {
451 this.encryption = encryption;
452 }
453
454 public int getStatus() {
455 return status;
456 }
457
458 public void setStatus(int status) {
459 this.status = status;
460 }
461
462 public String getRelativeFilePath() {
463 return this.relativeFilePath;
464 }
465
466 public void setRelativeFilePath(String path) {
467 this.relativeFilePath = path;
468 }
469
470 public String getRemoteMsgId() {
471 return this.remoteMsgId;
472 }
473
474 public void setRemoteMsgId(String id) {
475 this.remoteMsgId = id;
476 }
477
478 public String getServerMsgId() {
479 return this.serverMsgId;
480 }
481
482 public void setServerMsgId(String id) {
483 this.serverMsgId = id;
484 }
485
486 public boolean isRead() {
487 return this.read;
488 }
489
490 public boolean isDeleted() {
491 return this.deleted;
492 }
493
494 public void setDeleted(boolean deleted) {
495 this.deleted = deleted;
496 }
497
498 public void markRead() {
499 this.read = true;
500 }
501
502 public void markUnread() {
503 this.read = false;
504 }
505
506 public void setTime(long time) {
507 this.timeSent = time;
508 }
509
510 public String getEncryptedBody() {
511 return this.encryptedBody;
512 }
513
514 public void setEncryptedBody(String body) {
515 this.encryptedBody = body;
516 }
517
518 public int getType() {
519 return this.type;
520 }
521
522 public void setType(int type) {
523 this.type = type;
524 }
525
526 public boolean isCarbon() {
527 return carbon;
528 }
529
530 public void setCarbon(boolean carbon) {
531 this.carbon = carbon;
532 }
533
534 public void putEdited(String edited, String serverMsgId) {
535 final Edit edit = new Edit(edited, serverMsgId);
536 if (this.edits.size() < 128 && !this.edits.contains(edit)) {
537 this.edits.add(edit);
538 }
539 }
540
541 boolean remoteMsgIdMatchInEdit(String id) {
542 for (Edit edit : this.edits) {
543 if (id.equals(edit.getEditedId())) {
544 return true;
545 }
546 }
547 return false;
548 }
549
550 public String getBodyLanguage() {
551 return this.bodyLanguage;
552 }
553
554 public void setBodyLanguage(String language) {
555 this.bodyLanguage = language;
556 }
557
558 public boolean edited() {
559 return this.edits.size() > 0;
560 }
561
562 public void setTrueCounterpart(Jid trueCounterpart) {
563 this.trueCounterpart = trueCounterpart;
564 }
565
566 public Jid getTrueCounterpart() {
567 return this.trueCounterpart;
568 }
569
570 public Transferable getTransferable() {
571 return this.transferable;
572 }
573
574 public synchronized void setTransferable(Transferable transferable) {
575 this.transferable = transferable;
576 }
577
578 public boolean addReadByMarker(ReadByMarker readByMarker) {
579 if (readByMarker.getRealJid() != null) {
580 if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
581 return false;
582 }
583 } else if (readByMarker.getFullJid() != null) {
584 if (readByMarker.getFullJid().equals(counterpart)) {
585 return false;
586 }
587 }
588 if (this.readByMarkers.add(readByMarker)) {
589 if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
590 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
591 while (iterator.hasNext()) {
592 ReadByMarker marker = iterator.next();
593 if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
594 iterator.remove();
595 }
596 }
597 }
598 return true;
599 } else {
600 return false;
601 }
602 }
603
604 public Set<ReadByMarker> getReadByMarkers() {
605 return ImmutableSet.copyOf(this.readByMarkers);
606 }
607
608 boolean similar(Message message) {
609 if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
610 return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
611 } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
612 return true;
613 } else if (this.body == null || this.counterpart == null) {
614 return false;
615 } else {
616 String body, otherBody;
617 if (this.hasFileOnRemoteHost()) {
618 body = getFileParams().url;
619 otherBody = message.body == null ? null : message.body.trim();
620 } else {
621 body = this.body;
622 otherBody = message.body;
623 }
624 final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
625 if (message.getRemoteMsgId() != null) {
626 final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
627 if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
628 return true;
629 }
630 return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
631 && matchingCounterpart
632 && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
633 } else {
634 return this.remoteMsgId == null
635 && matchingCounterpart
636 && body.equals(otherBody)
637 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
638 }
639 }
640 }
641
642 public Message next() {
643 if (this.conversation instanceof Conversation) {
644 final Conversation conversation = (Conversation) this.conversation;
645 synchronized (conversation.messages) {
646 if (this.mNextMessage == null) {
647 int index = conversation.messages.indexOf(this);
648 if (index < 0 || index >= conversation.messages.size() - 1) {
649 this.mNextMessage = null;
650 } else {
651 this.mNextMessage = conversation.messages.get(index + 1);
652 }
653 }
654 return this.mNextMessage;
655 }
656 } else {
657 throw new AssertionError("Calling next should be disabled for stubs");
658 }
659 }
660
661 public Message prev() {
662 if (this.conversation instanceof Conversation) {
663 final Conversation conversation = (Conversation) this.conversation;
664 synchronized (conversation.messages) {
665 if (this.mPreviousMessage == null) {
666 int index = conversation.messages.indexOf(this);
667 if (index <= 0 || index > conversation.messages.size()) {
668 this.mPreviousMessage = null;
669 } else {
670 this.mPreviousMessage = conversation.messages.get(index - 1);
671 }
672 }
673 }
674 return this.mPreviousMessage;
675 } else {
676 throw new AssertionError("Calling prev should be disabled for stubs");
677 }
678 }
679
680 public boolean isLastCorrectableMessage() {
681 Message next = next();
682 while (next != null) {
683 if (next.isEditable()) {
684 return false;
685 }
686 next = next.next();
687 }
688 return isEditable();
689 }
690
691 public boolean isEditable() {
692 return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
693 }
694
695 public boolean mergeable(final Message message) {
696 return message != null &&
697 (message.getType() == Message.TYPE_TEXT &&
698 this.getTransferable() == null &&
699 message.getTransferable() == null &&
700 message.getEncryption() != Message.ENCRYPTION_PGP &&
701 message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
702 this.getType() == message.getType() &&
703 this.getSubject() != null &&
704 isStatusMergeable(this.getStatus(), message.getStatus()) &&
705 isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
706 this.getCounterpart() != null &&
707 this.getCounterpart().equals(message.getCounterpart()) &&
708 this.edited() == message.edited() &&
709 (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
710 this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
711 !message.isGeoUri() &&
712 !this.isGeoUri() &&
713 !message.isOOb() &&
714 !this.isOOb() &&
715 !message.treatAsDownloadable() &&
716 !this.treatAsDownloadable() &&
717 !message.hasMeCommand() &&
718 !this.hasMeCommand() &&
719 !this.bodyIsOnlyEmojis() &&
720 !message.bodyIsOnlyEmojis() &&
721 ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
722 UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
723 this.getReadByMarkers().equals(message.getReadByMarkers()) &&
724 !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
725 );
726 }
727
728 private static boolean isStatusMergeable(int a, int b) {
729 return a == b || (
730 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
731 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
732 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
733 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
734 || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
735 );
736 }
737
738 private static boolean isEncryptionMergeable(final int a, final int b) {
739 return a == b
740 && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
741 .contains(a);
742 }
743
744 public void setCounterparts(List<MucOptions.User> counterparts) {
745 this.counterparts = counterparts;
746 }
747
748 public List<MucOptions.User> getCounterparts() {
749 return this.counterparts;
750 }
751
752 @Override
753 public int getAvatarBackgroundColor() {
754 if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
755 return Color.TRANSPARENT;
756 } else {
757 return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
758 }
759 }
760
761 @Override
762 public String getAvatarName() {
763 return UIHelper.getMessageDisplayName(this);
764 }
765
766 public boolean isOOb() {
767 return oob || oobUri != null;
768 }
769
770 public static class MergeSeparator {
771 }
772
773 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
774 final Element html = getHtml();
775 if (html == null || Build.VERSION.SDK_INT < 24) {
776 return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
777 } else {
778 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
779 MessageUtils.filterLtrRtl(html.toString()).trim(),
780 Html.FROM_HTML_MODE_COMPACT,
781 (source) -> {
782 try {
783 if (thumbnailer == null) return fallbackImg;
784 Cid cid = BobTransfer.cid(new URI(source));
785 if (cid == null) return fallbackImg;
786 Drawable thumbnail = thumbnailer.getThumbnail(cid);
787 if (thumbnail == null) return fallbackImg;
788 return thumbnail;
789 } catch (final URISyntaxException e) {
790 return fallbackImg;
791 }
792 },
793 (opening, tag, output, xmlReader) -> {}
794 ));
795
796 // https://stackoverflow.com/a/10187511/8611
797 int i = spannable.length();
798 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
799 return (SpannableStringBuilder) spannable.subSequence(0, i+1);
800 }
801 }
802
803 public SpannableStringBuilder getMergedBody() {
804 return getMergedBody(null, null);
805 }
806
807 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
808 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
809 Message current = this;
810 while (current.mergeable(current.next())) {
811 current = current.next();
812 if (current == null) {
813 break;
814 }
815 body.append("\n\n");
816 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
817 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
818 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
819 }
820 return body;
821 }
822
823 public boolean hasMeCommand() {
824 return this.body.trim().startsWith(ME_COMMAND);
825 }
826
827 public int getMergedStatus() {
828 int status = this.status;
829 Message current = this;
830 while (current.mergeable(current.next())) {
831 current = current.next();
832 if (current == null) {
833 break;
834 }
835 status = current.status;
836 }
837 return status;
838 }
839
840 public long getMergedTimeSent() {
841 long time = this.timeSent;
842 Message current = this;
843 while (current.mergeable(current.next())) {
844 current = current.next();
845 if (current == null) {
846 break;
847 }
848 time = current.timeSent;
849 }
850 return time;
851 }
852
853 public boolean wasMergedIntoPrevious() {
854 Message prev = this.prev();
855 return prev != null && prev.mergeable(this);
856 }
857
858 public boolean trusted() {
859 Contact contact = this.getContact();
860 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
861 }
862
863 public boolean fixCounterpart() {
864 final Presences presences = conversation.getContact().getPresences();
865 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
866 return true;
867 } else if (presences.size() >= 1) {
868 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
869 return true;
870 } else {
871 counterpart = null;
872 return false;
873 }
874 }
875
876 public void setUuid(String uuid) {
877 this.uuid = uuid;
878 }
879
880 public String getEditedId() {
881 if (edits.size() > 0) {
882 return edits.get(edits.size() - 1).getEditedId();
883 } else {
884 throw new IllegalStateException("Attempting to store unedited message");
885 }
886 }
887
888 public String getEditedIdWireFormat() {
889 if (edits.size() > 0) {
890 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
891 } else {
892 throw new IllegalStateException("Attempting to store unedited message");
893 }
894 }
895
896 public URI getOob() {
897 return oobUri;
898 }
899
900 public void setOob(String oobUri) {
901 try {
902 this.oobUri = oobUri == null ? null : new URI(oobUri);
903 } catch (final URISyntaxException e) {
904 this.oobUri = null;
905 }
906 this.oob = this.oobUri != null;
907 }
908
909 public void addPayload(Element el) {
910 this.payloads.add(el);
911 }
912
913 public Element getHtml() {
914 if (this.payloads == null) return null;
915
916 for (Element el : this.payloads) {
917 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
918 return el.getChildren().get(0);
919 }
920 }
921
922 return null;
923 }
924
925 public List<Element> getCommands() {
926 if (this.payloads == null) return null;
927
928 for (Element el : this.payloads) {
929 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
930 return el.getChildren();
931 }
932 }
933
934 return null;
935 }
936
937 public String getMimeType() {
938 String extension;
939 if (relativeFilePath != null) {
940 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
941 } else {
942 final String url = URL.tryParse(oobUri == null ? body.split("\n")[0] : oobUri.toString());
943 if (url == null) {
944 return null;
945 }
946 extension = MimeUtils.extractRelevantExtension(url);
947 }
948 return MimeUtils.guessMimeTypeFromExtension(extension);
949 }
950
951 public synchronized boolean treatAsDownloadable() {
952 if (treatAsDownloadable == null) {
953 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
954 }
955 return treatAsDownloadable;
956 }
957
958 public synchronized boolean bodyIsOnlyEmojis() {
959 if (isEmojisOnly == null) {
960 isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
961 }
962 return isEmojisOnly;
963 }
964
965 public synchronized boolean isGeoUri() {
966 if (isGeoUri == null) {
967 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
968 }
969 return isGeoUri;
970 }
971
972 public synchronized void resetFileParams() {
973 this.fileParams = null;
974 }
975
976 public synchronized void setFileParams(FileParams fileParams) {
977 this.fileParams = fileParams;
978 }
979
980 public synchronized FileParams getFileParams() {
981 if (fileParams == null) {
982 fileParams = new FileParams(this.body);
983 if (this.transferable != null) {
984 fileParams.size = this.transferable.getFileSize();
985 }
986
987 if (oobUri != null && ("http".equalsIgnoreCase(oobUri.getScheme()) || "https".equalsIgnoreCase(oobUri.getScheme()) || "cid".equalsIgnoreCase(oobUri.getScheme()))) {
988 fileParams.url = oobUri.toString();
989 }
990 }
991 return fileParams;
992 }
993
994 private static int parseInt(String value) {
995 try {
996 return Integer.parseInt(value);
997 } catch (NumberFormatException e) {
998 return 0;
999 }
1000 }
1001
1002 public void untie() {
1003 this.mNextMessage = null;
1004 this.mPreviousMessage = null;
1005 }
1006
1007 public boolean isPrivateMessage() {
1008 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1009 }
1010
1011 public boolean isFileOrImage() {
1012 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1013 }
1014
1015
1016 public boolean isTypeText() {
1017 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1018 }
1019
1020 public boolean hasFileOnRemoteHost() {
1021 return isFileOrImage() && getFileParams().url != null;
1022 }
1023
1024 public boolean needsUploading() {
1025 return isFileOrImage() && getFileParams().url == null;
1026 }
1027
1028 public static class FileParams {
1029 public String url;
1030 public Long size = null;
1031 public int width = 0;
1032 public int height = 0;
1033 public int runtime = 0;
1034
1035 public FileParams() { }
1036
1037 public FileParams(String ser) {
1038 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1039 switch (parts.length) {
1040 case 1:
1041 try {
1042 this.size = Long.parseLong(parts[0]);
1043 } catch (final NumberFormatException e) {
1044 this.url = URL.tryParse(parts[0]);
1045 }
1046 break;
1047 case 5:
1048 this.runtime = parseInt(parts[4]);
1049 case 4:
1050 this.width = parseInt(parts[2]);
1051 this.height = parseInt(parts[3]);
1052 case 2:
1053 this.url = URL.tryParse(parts[0]);
1054 this.size = Longs.tryParse(parts[1]);
1055 break;
1056 case 3:
1057 this.size = Longs.tryParse(parts[0]);
1058 this.width = parseInt(parts[1]);
1059 this.height = parseInt(parts[2]);
1060 break;
1061 }
1062 }
1063
1064 public long getSize() {
1065 return size == null ? 0 : size;
1066 }
1067
1068 public String toString() {
1069 final StringBuilder builder = new StringBuilder();
1070 if (url != null) builder.append(url);
1071 if (size != null) builder.append('|').append(size.toString());
1072 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1073 if (height > 0 || runtime > 0) builder.append('|').append(height);
1074 if (runtime > 0) builder.append('|').append(runtime);
1075 return builder.toString();
1076 }
1077 }
1078
1079 public void setFingerprint(String fingerprint) {
1080 this.axolotlFingerprint = fingerprint;
1081 }
1082
1083 public String getFingerprint() {
1084 return axolotlFingerprint;
1085 }
1086
1087 public boolean isTrusted() {
1088 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1089 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1090 return s != null && s.isTrusted();
1091 }
1092
1093 private int getPreviousEncryption() {
1094 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1095 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1096 continue;
1097 }
1098 return iterator.getEncryption();
1099 }
1100 return ENCRYPTION_NONE;
1101 }
1102
1103 private int getNextEncryption() {
1104 if (this.conversation instanceof Conversation) {
1105 Conversation conversation = (Conversation) this.conversation;
1106 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1107 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1108 continue;
1109 }
1110 return iterator.getEncryption();
1111 }
1112 return conversation.getNextEncryption();
1113 } else {
1114 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1115 }
1116 }
1117
1118 public boolean isValidInSession() {
1119 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1120 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1121
1122 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1123 || futureEncryption == ENCRYPTION_NONE
1124 || pastEncryption != futureEncryption;
1125
1126 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1127 }
1128
1129 private static int getCleanedEncryption(int encryption) {
1130 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1131 return ENCRYPTION_PGP;
1132 }
1133 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1134 return ENCRYPTION_AXOLOTL;
1135 }
1136 return encryption;
1137 }
1138
1139 public static boolean configurePrivateMessage(final Message message) {
1140 return configurePrivateMessage(message, false);
1141 }
1142
1143 public static boolean configurePrivateFileMessage(final Message message) {
1144 return configurePrivateMessage(message, true);
1145 }
1146
1147 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1148 final Conversation conversation;
1149 if (message.conversation instanceof Conversation) {
1150 conversation = (Conversation) message.conversation;
1151 } else {
1152 return false;
1153 }
1154 if (conversation.getMode() == Conversation.MODE_MULTI) {
1155 final Jid nextCounterpart = conversation.getNextCounterpart();
1156 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1157 }
1158 return false;
1159 }
1160
1161 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1162 final Conversation conversation;
1163 if (message.conversation instanceof Conversation) {
1164 conversation = (Conversation) message.conversation;
1165 } else {
1166 return false;
1167 }
1168 return configurePrivateMessage(conversation, message, counterpart, false);
1169 }
1170
1171 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1172 if (counterpart == null) {
1173 return false;
1174 }
1175 message.setCounterpart(counterpart);
1176 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1177 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1178 return true;
1179 }
1180}