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