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