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