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