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