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