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