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