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