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