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