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.text.Spanned;
11import android.text.style.ImageSpan;
12import android.text.style.ClickableSpan;
13import android.util.Base64;
14import android.util.Log;
15import android.util.Pair;
16import android.view.View;
17
18import com.cheogram.android.BobTransfer;
19import com.cheogram.android.GetThumbnailForCid;
20import com.cheogram.android.InlineImageSpan;
21import com.cheogram.android.SpannedToXHTML;
22
23import com.google.common.io.ByteSource;
24import com.google.common.base.Strings;
25import com.google.common.collect.Collections2;
26import com.google.common.collect.ImmutableSet;
27import com.google.common.primitives.Longs;
28
29import org.json.JSONException;
30
31import java.io.IOException;
32import java.lang.ref.WeakReference;
33import java.net.URI;
34import java.net.URISyntaxException;
35import java.security.NoSuchAlgorithmException;
36import java.time.Duration;
37import java.util.ArrayList;
38import java.util.Arrays;
39import java.util.Collection;
40import java.util.Collections;
41import java.util.HashSet;
42import java.util.Iterator;
43import java.util.List;
44import java.util.Set;
45import java.util.concurrent.CopyOnWriteArraySet;
46import java.util.stream.Collectors;
47
48import io.ipfs.cid.Cid;
49
50import eu.siacs.conversations.Config;
51import eu.siacs.conversations.crypto.axolotl.AxolotlService;
52import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
53import eu.siacs.conversations.http.URL;
54import eu.siacs.conversations.services.AvatarService;
55import eu.siacs.conversations.ui.util.MyLinkify;
56import eu.siacs.conversations.ui.util.PresenceSelector;
57import eu.siacs.conversations.ui.util.QuoteHelper;
58import eu.siacs.conversations.utils.CryptoHelper;
59import eu.siacs.conversations.utils.Emoticons;
60import eu.siacs.conversations.utils.GeoHelper;
61import eu.siacs.conversations.utils.MessageUtils;
62import eu.siacs.conversations.utils.MimeUtils;
63import eu.siacs.conversations.utils.StringUtils;
64import eu.siacs.conversations.utils.UIHelper;
65import eu.siacs.conversations.services.XmppConnectionService;
66import eu.siacs.conversations.xmpp.Jid;
67import eu.siacs.conversations.xml.Element;
68import eu.siacs.conversations.xml.Namespace;
69import eu.siacs.conversations.xml.Tag;
70import eu.siacs.conversations.xml.XmlReader;
71
72public class Message extends AbstractEntity implements AvatarService.Avatarable {
73
74 public static final String TABLENAME = "messages";
75
76 public static final int STATUS_DUMMY = -1;
77 public static final int STATUS_RECEIVED = 0;
78 public static final int STATUS_UNSEND = 1;
79 public static final int STATUS_SEND = 2;
80 public static final int STATUS_SEND_FAILED = 3;
81 public static final int STATUS_WAITING = 5;
82 public static final int STATUS_OFFERED = 6;
83 public static final int STATUS_SEND_RECEIVED = 7;
84 public static final int STATUS_SEND_DISPLAYED = 8;
85
86 public static final int ENCRYPTION_NONE = 0;
87 public static final int ENCRYPTION_PGP = 1;
88 public static final int ENCRYPTION_OTR = 2;
89 public static final int ENCRYPTION_DECRYPTED = 3;
90 public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
91 public static final int ENCRYPTION_AXOLOTL = 5;
92 public static final int ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE = 6;
93 public static final int ENCRYPTION_AXOLOTL_FAILED = 7;
94
95 public static final int TYPE_TEXT = 0;
96 public static final int TYPE_IMAGE = 1;
97 public static final int TYPE_FILE = 2;
98 public static final int TYPE_STATUS = 3;
99 public static final int TYPE_PRIVATE = 4;
100 public static final int TYPE_PRIVATE_FILE = 5;
101 public static final int TYPE_RTP_SESSION = 6;
102
103 public static final String CONVERSATION = "conversationUuid";
104 public static final String COUNTERPART = "counterpart";
105 public static final String TRUE_COUNTERPART = "trueCounterpart";
106 public static final String BODY = "body";
107 public static final String BODY_LANGUAGE = "bodyLanguage";
108 public static final String TIME_SENT = "timeSent";
109 public static final String ENCRYPTION = "encryption";
110 public static final String STATUS = "status";
111 public static final String TYPE = "type";
112 public static final String CARBON = "carbon";
113 public static final String OOB = "oob";
114 public static final String EDITED = "edited";
115 public static final String REMOTE_MSG_ID = "remoteMsgId";
116 public static final String SERVER_MSG_ID = "serverMsgId";
117 public static final String RELATIVE_FILE_PATH = "relativeFilePath";
118 public static final String FINGERPRINT = "axolotl_fingerprint";
119 public static final String READ = "read";
120 public static final String ERROR_MESSAGE = "errorMsg";
121 public static final String READ_BY_MARKERS = "readByMarkers";
122 public static final String MARKABLE = "markable";
123 public static final String DELETED = "deleted";
124 public static final String OCCUPANT_ID = "occupantId";
125 public static final String REACTIONS = "reactions";
126 public static final String ME_COMMAND = "/me ";
127
128 public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
129
130 public static final Object PLAIN_TEXT_SPAN = new PlainTextSpan();
131
132 public boolean markable = false;
133 protected String conversationUuid;
134 protected Jid counterpart;
135 protected Jid trueCounterpart;
136 protected String occupantId = null;
137 protected String body;
138 protected String subject;
139 protected String encryptedBody;
140 protected long timeSent;
141 protected long timeReceived;
142 protected int encryption;
143 protected int status;
144 protected int type;
145 protected boolean deleted = false;
146 protected boolean carbon = false;
147 private boolean oob = false;
148 protected List<Element> payloads = new ArrayList<>();
149 protected List<Edit> edits = new ArrayList<>();
150 protected String relativeFilePath;
151 protected boolean read = true;
152 protected String remoteMsgId = null;
153 private String bodyLanguage = null;
154 protected String serverMsgId = null;
155 private final Conversational conversation;
156 protected Transferable transferable = null;
157 private Message mNextMessage = null;
158 private Message mPreviousMessage = null;
159 private String axolotlFingerprint = null;
160 private String errorMessage = null;
161 private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
162 protected Message mInReplyTo = null;
163 private Collection<Reaction> reactions = Collections.emptyList();
164
165 private Boolean isGeoUri = null;
166 private Boolean isEmojisOnly = null;
167 private Boolean treatAsDownloadable = null;
168 private FileParams fileParams = null;
169 private List<MucOptions.User> counterparts;
170 private WeakReference<MucOptions.User> user;
171
172 protected Message(Conversational conversation) {
173 this.conversation = conversation;
174 }
175
176 public Message(Conversational conversation, String body, int encryption) {
177 this(conversation, body, encryption, STATUS_UNSEND);
178 }
179
180 public Message(Conversational conversation, String body, int encryption, int status) {
181 this(conversation, java.util.UUID.randomUUID().toString(),
182 conversation.getUuid(),
183 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
184 null,
185 body,
186 System.currentTimeMillis(),
187 encryption,
188 status,
189 TYPE_TEXT,
190 false,
191 null,
192 null,
193 null,
194 null,
195 true,
196 null,
197 false,
198 null,
199 null,
200 false,
201 false,
202 null,
203 null,
204 Collections.emptyList(),
205 System.currentTimeMillis(),
206 null,
207 null,
208 null);
209 }
210
211 public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
212 this(conversation, java.util.UUID.randomUUID().toString(),
213 conversation.getUuid(),
214 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
215 null,
216 null,
217 System.currentTimeMillis(),
218 Message.ENCRYPTION_NONE,
219 status,
220 type,
221 false,
222 remoteMsgId,
223 null,
224 null,
225 null,
226 true,
227 null,
228 false,
229 null,
230 null,
231 false,
232 false,
233 null,
234 null,
235 Collections.emptyList(),
236 System.currentTimeMillis(),
237 null,
238 null,
239 null);
240 }
241
242 protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
243 final Jid trueCounterpart, final String body, final long timeSent,
244 final int encryption, final int status, final int type, final boolean carbon,
245 final String remoteMsgId, final String relativeFilePath,
246 final String serverMsgId, final String fingerprint, final boolean read,
247 final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
248 final boolean markable, final boolean deleted, final String bodyLanguage, final String occupantId, final Collection<Reaction> reactions, final long timeReceived, final String subject, final String fileParams, final List<Element> payloads) {
249 this.conversation = conversation;
250 this.uuid = uuid;
251 this.conversationUuid = conversationUUid;
252 this.counterpart = counterpart;
253 this.trueCounterpart = trueCounterpart;
254 this.body = body == null ? "" : body;
255 this.timeSent = timeSent;
256 this.encryption = encryption;
257 this.status = status;
258 this.type = type;
259 this.carbon = carbon;
260 this.remoteMsgId = remoteMsgId;
261 this.relativeFilePath = relativeFilePath;
262 this.serverMsgId = serverMsgId;
263 this.axolotlFingerprint = fingerprint;
264 this.read = read;
265 this.edits = Edit.fromJson(edited);
266 this.oob = oob;
267 this.errorMessage = errorMessage;
268 this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
269 this.markable = markable;
270 this.deleted = deleted;
271 this.bodyLanguage = bodyLanguage;
272 this.occupantId = occupantId;
273 this.reactions = reactions;
274 this.timeReceived = timeReceived;
275 this.subject = subject;
276 if (payloads != null) this.payloads = payloads;
277 if (fileParams != null && getSims().isEmpty()) this.fileParams = new FileParams(fileParams);
278 }
279
280 public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
281 String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
282 List<Element> payloads = new ArrayList<>();
283 if (payloadsStr != null) {
284 final XmlReader xmlReader = new XmlReader();
285 xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
286 Tag tag;
287 try {
288 while ((tag = xmlReader.readTag()) != null) {
289 payloads.add(xmlReader.readElement(tag));
290 }
291 } catch (IOException e) {
292 Log.e(Config.LOGTAG, "Failed to parse: " + payloadsStr, e);
293 }
294 }
295
296 Message m = new Message(conversation,
297 cursor.getString(cursor.getColumnIndex(UUID)),
298 cursor.getString(cursor.getColumnIndex(CONVERSATION)),
299 fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
300 fromString(cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART))),
301 cursor.getString(cursor.getColumnIndex(BODY)),
302 cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
303 cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
304 cursor.getInt(cursor.getColumnIndex(STATUS)),
305 cursor.getInt(cursor.getColumnIndex(TYPE)),
306 cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
307 cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
308 cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
309 cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
310 cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
311 cursor.getInt(cursor.getColumnIndex(READ)) > 0,
312 cursor.getString(cursor.getColumnIndex(EDITED)),
313 cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
314 cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
315 ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))),
316 cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
317 cursor.getInt(cursor.getColumnIndex(DELETED)) > 0,
318 cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
319 cursor.getString(cursor.getColumnIndexOrThrow(OCCUPANT_ID)),
320 Reaction.fromString(cursor.getString(cursor.getColumnIndexOrThrow(REACTIONS))),
321 cursor.getLong(cursor.getColumnIndex(cursor.isNull(cursor.getColumnIndex("timeReceived")) ? TIME_SENT : "timeReceived")),
322 cursor.getString(cursor.getColumnIndex("subject")),
323 cursor.getString(cursor.getColumnIndex("fileParams")),
324 payloads
325 );
326 final var legacyOccupant = cursor.getString(cursor.getColumnIndex("occupant_id"));
327 if (legacyOccupant != null) m.setOccupantId(legacyOccupant);
328 return m;
329 }
330
331 private static Jid fromString(String value) {
332 try {
333 if (value != null) {
334 return Jid.of(value);
335 }
336 } catch (IllegalArgumentException e) {
337 return null;
338 }
339 return null;
340 }
341
342 public static Message createStatusMessage(Conversation conversation, String body) {
343 final Message message = new Message(conversation);
344 message.setType(Message.TYPE_STATUS);
345 message.setStatus(Message.STATUS_RECEIVED);
346 message.body = body;
347 return message;
348 }
349
350 public static Message createLoadMoreMessage(Conversation conversation) {
351 final Message message = new Message(conversation);
352 message.setType(Message.TYPE_STATUS);
353 message.body = "LOAD_MORE";
354 return message;
355 }
356
357 public ContentValues getCheogramContentValues() {
358 final FileParams fp = fileParams;
359 ContentValues values = new ContentValues();
360 values.put(UUID, uuid);
361 values.put("subject", subject);
362 values.put("fileParams", fp == null ? null : fp.toString());
363 if (fp != null && !fp.isEmpty()) {
364 List<Element> sims = getSims();
365 if (sims.isEmpty()) {
366 addPayload(fp.toSims());
367 } else {
368 sims.get(0).replaceChildren(fp.toSims().getChildren());
369 }
370 }
371 values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
372 values.put("occupant_id", occupantId);
373 return values;
374 }
375
376 @Override
377 public ContentValues getContentValues() {
378 final var values = new ContentValues();
379 values.put(UUID, uuid);
380 values.put(CONVERSATION, conversationUuid);
381 if (counterpart == null) {
382 values.putNull(COUNTERPART);
383 } else {
384 values.put(COUNTERPART, counterpart.toString());
385 }
386 if (trueCounterpart == null) {
387 values.putNull(TRUE_COUNTERPART);
388 } else {
389 values.put(TRUE_COUNTERPART, trueCounterpart.toString());
390 }
391 values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
392 values.put(TIME_SENT, timeSent);
393 values.put(ENCRYPTION, encryption);
394 values.put(STATUS, status);
395 values.put(TYPE, type);
396 values.put(CARBON, carbon ? 1 : 0);
397 values.put(REMOTE_MSG_ID, remoteMsgId);
398 values.put(RELATIVE_FILE_PATH, relativeFilePath);
399 values.put(SERVER_MSG_ID, serverMsgId);
400 values.put(FINGERPRINT, axolotlFingerprint);
401 values.put(READ, read ? 1 : 0);
402 try {
403 values.put(EDITED, Edit.toJson(edits));
404 } catch (JSONException e) {
405 Log.e(Config.LOGTAG, "error persisting json for edits", e);
406 }
407 values.put(OOB, oob ? 1 : 0);
408 values.put(ERROR_MESSAGE, errorMessage);
409 values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
410 values.put(MARKABLE, markable ? 1 : 0);
411 values.put(DELETED, deleted ? 1 : 0);
412 values.put(BODY_LANGUAGE, bodyLanguage);
413 values.put(OCCUPANT_ID, occupantId);
414 values.put(REACTIONS, Reaction.toString(this.reactions));
415 return values;
416 }
417
418 public String replyId() {
419 if (conversation.getMode() == Conversation.MODE_MULTI) return getServerMsgId();
420 final String remote = getRemoteMsgId();
421 if (remote == null && getStatus() > STATUS_RECEIVED) return getUuid();
422 return remote;
423 }
424
425 public Message reply() {
426 Message m = new Message(conversation, "", ENCRYPTION_NONE);
427 m.setThread(getThread());
428
429 m.updateReplyTo(this, null);
430 return m;
431 }
432
433 public void clearReplyReact() {
434 this.payloads.remove(getReactionsEl());
435 this.payloads.remove(getReply());
436 clearFallbacks("urn:xmpp:reply:0", "urn:xmpp:reactions:0");
437 }
438
439 public void updateReplyTo(final Message replyTo, Spanned body) {
440 clearReplyReact();
441
442 if (body == null) body = new SpannableStringBuilder(getBody(true));
443 setBody(QuoteHelper.quote(MessageUtils.prepareQuote(replyTo)) + "\n");
444
445 final String replyId = replyTo.replyId();
446 if (replyId == null) return;
447
448 addPayload(
449 new Element("reply", "urn:xmpp:reply:0")
450 .setAttribute("to", replyTo.getCounterpart())
451 .setAttribute("id", replyId)
452 );
453 final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0");
454 fallback.addChild("body", "urn:xmpp:fallback:0")
455 .setAttribute("start", "0")
456 .setAttribute("end", "" + this.body.codePointCount(0, this.body.length()));
457 addPayload(fallback);
458
459 appendBody(body);
460 setInReplyTo(replyTo);
461 }
462
463 public Message react(String emoji) {
464 final var m = reply();
465 if (getReactionsEl() == null) {
466 m.updateReaction(this, emoji);
467 } else if (mInReplyTo != null) {
468 // Try to send react-to-reaction to parent
469 m.updateReaction(mInReplyTo, emoji);
470 } else {
471 // Do not send react-to-reaction
472 m.updateReplyTo(this, new SpannableStringBuilder(emoji));
473 }
474 return m;
475 }
476
477 public void updateReaction(final Message reactTo, String emoji) {
478 Set<String> emojis = new HashSet<>();
479 if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(reactTo.replyId(), null);
480 emojis.remove(getBody(true));
481 emojis.add(emoji);
482
483 updateReplyTo(reactTo, new SpannableStringBuilder(emoji));
484 final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
485 fallback.addChild("body", "urn:xmpp:fallback:0");
486 addPayload(fallback);
487 final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", reactTo.replyId());
488 for (String oneEmoji : emojis) {
489 reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji);
490 }
491 addPayload(reactions);
492 }
493
494 public Element getReply() {
495 if (this.payloads == null) return null;
496
497 for (Element el : this.payloads) {
498 if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) {
499 return el;
500 }
501 }
502
503 return null;
504 }
505
506 public boolean isAttention() {
507 if (this.payloads == null) return false;
508
509 for (Element el : this.payloads) {
510 if (el.getName().equals("attention") && el.getNamespace().equals("urn:xmpp:attention:0")) {
511 return true;
512 }
513 }
514
515 return false;
516 }
517
518 public String getConversationUuid() {
519 return conversationUuid;
520 }
521
522 public Conversational getConversation() {
523 return this.conversation;
524 }
525
526 public Jid getCounterpart() {
527 return counterpart;
528 }
529
530 public void setCounterpart(final Jid counterpart) {
531 this.counterpart = counterpart;
532 }
533
534 public Contact getContact() {
535 if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
536 if (this.trueCounterpart != null) {
537 return this.conversation.getAccount().getRoster()
538 .getContact(this.trueCounterpart);
539 }
540
541 return this.conversation.getContact();
542 } else {
543 if (this.trueCounterpart == null) {
544 return null;
545 } else {
546 return this.conversation.getAccount().getRoster()
547 .getContactFromContactList(this.trueCounterpart);
548 }
549 }
550 }
551
552 public String getQuoteableBody() {
553 if (this.body == null) return null;
554
555 StringBuilder body = bodyMinusFallbacks("http://jabber.org/protocol/address").first;
556 return body.toString();
557 }
558
559 public String getRawBody() {
560 return this.body;
561 }
562
563 private Pair<StringBuilder, Boolean> bodyMinusFallbacks(String... fallbackNames) {
564 StringBuilder body = new StringBuilder(this.body == null ? "" : this.body);
565
566 List<Element> fallbacks = getFallbacks(fallbackNames);
567 List<Pair<Integer, Integer>> spans = new ArrayList<>();
568 for (Element fallback : fallbacks) {
569 for (Element span : fallback.getChildren()) {
570 if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
571 if (span.getAttribute("start") == null || span.getAttribute("end") == null) return new Pair<>(new StringBuilder(""), true);
572 spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
573 }
574 }
575 // Do them in reverse order so that span deletions don't affect the indexes of other spans
576 spans.sort((x, y) -> y.first.compareTo(x.first));
577 try {
578 for (Pair<Integer, Integer> span : spans) {
579 body.delete(body.offsetByCodePoints(0, span.first.intValue()), body.offsetByCodePoints(0, span.second.intValue()));
580 }
581 } catch (final IndexOutOfBoundsException e) { spans.clear(); }
582
583 return new Pair<>(body, !spans.isEmpty());
584 }
585
586 public String getBody() {
587 return getBody(false);
588 }
589
590 public String getBody(final boolean removeQuoteFallbacks) {
591 if (body == null) return "";
592
593 List<String> fallbacksToRemove = new ArrayList<>();
594 fallbacksToRemove.add("http://jabber.org/protocol/address");
595 if (getOob() != null || isGeoUri()) fallbacksToRemove.add(Namespace.OOB);
596 if (removeQuoteFallbacks) fallbacksToRemove.add("urn:xmpp:reply:0");
597 Pair<StringBuilder, Boolean> result = bodyMinusFallbacks(fallbacksToRemove.toArray(new String[0]));
598 StringBuilder body = result.first;
599
600 final String aesgcm = MessageUtils.aesgcmDownloadable(body.toString());
601 if (!result.second && aesgcm != null) {
602 return body.toString().replace(aesgcm, "");
603 } else if (!result.second && getOob() != null) {
604 return body.toString().replace(getOob().toString(), "");
605 } else if (!result.second && isGeoUri()) {
606 return "";
607 } else {
608 return body.toString();
609 }
610 }
611
612 public synchronized void clearFallbacks(String... includeFor) {
613 this.payloads.removeAll(getFallbacks(includeFor));
614 }
615
616 public synchronized Element getOrMakeHtml() {
617 Element html = getHtml();
618 if (html != null) return html;
619 html = new Element("html", "http://jabber.org/protocol/xhtml-im");
620 Element body = html.addChild("body", "http://www.w3.org/1999/xhtml");
621 SpannedToXHTML.append(body, new SpannableStringBuilder(getBody(true)));
622 addPayload(html);
623 return body;
624 }
625
626 public synchronized void setBody(Spanned span) {
627 // Don't bother removing, we'll edit below
628 setBodyPreserveXHTML(span == null ? null : span.toString());
629 if (span == null || SpannedToXHTML.isPlainText(span)) {
630 this.payloads.remove(getHtml(true));
631 } else {
632 final Element body = getOrMakeHtml();
633 body.clearChildren();
634 SpannedToXHTML.append(body, span);
635 }
636 }
637
638 public synchronized void setHtml(Element html) {
639 final Element oldHtml = getHtml(true);
640 if (oldHtml != null) this.payloads.remove(oldHtml);
641 if (html != null) addPayload(html);
642 }
643
644 private synchronized void setBodyPreserveXHTML(String body) {
645 this.body = body;
646 this.isGeoUri = null;
647 this.isEmojisOnly = null;
648 this.treatAsDownloadable = null;
649 }
650
651 public synchronized void setBody(String body) {
652 setBodyPreserveXHTML(body);
653 this.payloads.remove(getHtml(true));
654 }
655
656 public synchronized void appendBody(Spanned append) {
657 if (!SpannedToXHTML.isPlainText(append) || getHtml() != null) {
658 final Element body = getOrMakeHtml();
659 SpannedToXHTML.append(body, append);
660 }
661 appendBody(append.toString());
662 }
663
664 public synchronized void appendBody(String append) {
665 this.body += append;
666 this.isGeoUri = null;
667 this.isEmojisOnly = null;
668 this.treatAsDownloadable = null;
669 }
670
671 public String getSubject() {
672 return subject;
673 }
674
675 public synchronized void setSubject(String subject) {
676 this.subject = subject;
677 }
678
679 public Element getThread() {
680 if (this.payloads == null) return null;
681
682 for (Element el : this.payloads) {
683 if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
684 return el;
685 }
686 }
687
688 return null;
689 }
690
691 public void setThread(Element thread) {
692 payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
693 addPayload(thread);
694 }
695
696 public void setOccupantId(final String id) {
697 occupantId = id;
698 }
699
700 public String getOccupantId() {
701 return occupantId;
702 }
703
704 public void setMucUser(MucOptions.User user) {
705 this.user = new WeakReference<>(user);
706 if (user != null && user.getOccupantId() != null) setOccupantId(user.getOccupantId());
707 }
708
709 public boolean sameMucUser(Message otherMessage) {
710 final MucOptions.User thisUser = this.user == null ? null : this.user.get();
711 final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
712 return
713 (thisUser != null && thisUser == otherUser) ||
714 (getOccupantId() != null && getOccupantId().equals(otherMessage.getOccupantId()));
715 }
716
717 public String getErrorMessage() {
718 return errorMessage;
719 }
720
721 public boolean setErrorMessage(String message) {
722 boolean changed = (message != null && !message.equals(errorMessage))
723 || (message == null && errorMessage != null);
724 this.errorMessage = message;
725 return changed;
726 }
727
728 public long getTimeReceived() {
729 return timeReceived;
730 }
731
732 public long getTimeSent() {
733 return timeSent;
734 }
735
736 public int getEncryption() {
737 return encryption;
738 }
739
740 public void setEncryption(int encryption) {
741 this.encryption = encryption;
742 }
743
744 public int getStatus() {
745 return status;
746 }
747
748 public void setStatus(int status) {
749 this.status = status;
750 }
751
752 public String getRelativeFilePath() {
753 return this.relativeFilePath;
754 }
755
756 public void setRelativeFilePath(String path) {
757 this.relativeFilePath = path;
758 }
759
760 public String getRemoteMsgId() {
761 return this.remoteMsgId;
762 }
763
764 public void setRemoteMsgId(String id) {
765 this.remoteMsgId = id;
766 }
767
768 public String getServerMsgId() {
769 return this.serverMsgId;
770 }
771
772 public void setServerMsgId(String id) {
773 this.serverMsgId = id;
774 }
775
776 public boolean isRead() {
777 return this.read;
778 }
779
780 public boolean isDeleted() {
781 return this.deleted;
782 }
783
784 public Element getModerated() {
785 if (this.payloads == null) return null;
786
787 for (Element el : this.payloads) {
788 if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
789 return el;
790 }
791 }
792
793 return null;
794 }
795
796 public void setDeleted(boolean deleted) {
797 this.deleted = deleted;
798 }
799
800 public void markRead() {
801 this.read = true;
802 }
803
804 public void markUnread() {
805 this.read = false;
806 }
807
808 public void setTime(long time) {
809 this.timeSent = time;
810 }
811
812 public void setTimeReceived(long time) {
813 this.timeReceived = time;
814 }
815
816 public String getEncryptedBody() {
817 return this.encryptedBody;
818 }
819
820 public void setEncryptedBody(String body) {
821 this.encryptedBody = body;
822 }
823
824 public int getType() {
825 return this.type;
826 }
827
828 public void setType(int type) {
829 this.type = type;
830 }
831
832 public boolean isCarbon() {
833 return carbon;
834 }
835
836 public void setCarbon(boolean carbon) {
837 this.carbon = carbon;
838 }
839
840 public void putEdited(String edited, String serverMsgId) {
841 final Edit edit = new Edit(edited, serverMsgId);
842 if (this.edits.size() < 128 && !this.edits.contains(edit)) {
843 this.edits.add(edit);
844 }
845 }
846
847 boolean remoteMsgIdMatchInEdit(String id) {
848 for (Edit edit : this.edits) {
849 if (id.equals(edit.getEditedId())) {
850 return true;
851 }
852 }
853 return false;
854 }
855
856 public String getBodyLanguage() {
857 return this.bodyLanguage;
858 }
859
860 public void setBodyLanguage(String language) {
861 this.bodyLanguage = language;
862 }
863
864 public boolean edited() {
865 return this.edits.size() > 0;
866 }
867
868 public void setTrueCounterpart(Jid trueCounterpart) {
869 this.trueCounterpart = trueCounterpart;
870 }
871
872 public Jid getTrueCounterpart() {
873 return this.trueCounterpart;
874 }
875
876 public Transferable getTransferable() {
877 return this.transferable;
878 }
879
880 public synchronized void setTransferable(Transferable transferable) {
881 this.transferable = transferable;
882 }
883
884 public boolean addReadByMarker(final ReadByMarker readByMarker) {
885 if (readByMarker.getRealJid() != null) {
886 if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
887 return false;
888 }
889 } else if (readByMarker.getFullJid() != null) {
890 if (readByMarker.getFullJid().equals(counterpart)) {
891 return false;
892 }
893 }
894 if (this.readByMarkers.add(readByMarker)) {
895 if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
896 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
897 while (iterator.hasNext()) {
898 ReadByMarker marker = iterator.next();
899 if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
900 iterator.remove();
901 }
902 }
903 }
904 return true;
905 } else {
906 return false;
907 }
908 }
909
910 public Set<ReadByMarker> getReadByMarkers() {
911 return ImmutableSet.copyOf(this.readByMarkers);
912 }
913
914 public Set<Jid> getReadyByTrue() {
915 return ImmutableSet.copyOf(
916 Collections2.transform(
917 Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
918 ReadByMarker::getRealJid));
919 }
920
921 public void setInReplyTo(final Message m) {
922 mInReplyTo = m;
923 }
924
925 public Message getInReplyTo() {
926 return mInReplyTo;
927 }
928
929 boolean similar(Message message) {
930 if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
931 return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
932 } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
933 return true;
934 } else if (this.body == null || this.counterpart == null) {
935 return false;
936 } else {
937 String body, otherBody;
938 if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
939 body = getFileParams().url;
940 otherBody = message.body == null ? null : message.body.trim();
941 } else {
942 body = this.body;
943 otherBody = message.body;
944 }
945 final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
946 if (message.getRemoteMsgId() != null) {
947 final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
948 if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
949 return true;
950 }
951 return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
952 && matchingCounterpart
953 && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
954 } else {
955 return this.remoteMsgId == null
956 && matchingCounterpart
957 && body.equals(otherBody)
958 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
959 }
960 }
961 }
962
963 public Message next() {
964 if (this.conversation instanceof Conversation) {
965 final Conversation conversation = (Conversation) this.conversation;
966 synchronized (conversation.messages) {
967 if (this.mNextMessage == null) {
968 int index = conversation.messages.indexOf(this);
969 if (index < 0 || index >= conversation.messages.size() - 1) {
970 this.mNextMessage = null;
971 } else {
972 this.mNextMessage = conversation.messages.get(index + 1);
973 }
974 }
975 return this.mNextMessage;
976 }
977 } else {
978 throw new AssertionError("Calling next should be disabled for stubs");
979 }
980 }
981
982 public Message prev() {
983 if (this.conversation instanceof Conversation) {
984 final Conversation conversation = (Conversation) this.conversation;
985 synchronized (conversation.messages) {
986 if (this.mPreviousMessage == null) {
987 int index = conversation.messages.indexOf(this);
988 if (index <= 0 || index > conversation.messages.size()) {
989 this.mPreviousMessage = null;
990 } else {
991 this.mPreviousMessage = conversation.messages.get(index - 1);
992 }
993 }
994 }
995 return this.mPreviousMessage;
996 } else {
997 throw new AssertionError("Calling prev should be disabled for stubs");
998 }
999 }
1000
1001 public boolean isLastCorrectableMessage() {
1002 Message next = next();
1003 while (next != null) {
1004 if (next.isEditable()) {
1005 return false;
1006 }
1007 next = next.next();
1008 }
1009 return isEditable();
1010 }
1011
1012 public boolean isEditable() {
1013 return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
1014 }
1015
1016 public boolean mergeable(final Message message) {
1017 return false; // Merging messages messes up reply, so disable for now
1018 }
1019
1020 private static boolean isStatusMergeable(int a, int b) {
1021 return a == b || (
1022 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
1023 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
1024 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
1025 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
1026 || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
1027 );
1028 }
1029
1030 private static boolean isEncryptionMergeable(final int a, final int b) {
1031 return a == b
1032 && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
1033 .contains(a);
1034 }
1035
1036 public void setCounterparts(List<MucOptions.User> counterparts) {
1037 this.counterparts = counterparts;
1038 }
1039
1040 public List<MucOptions.User> getCounterparts() {
1041 return this.counterparts;
1042 }
1043
1044 @Override
1045 public int getAvatarBackgroundColor() {
1046 if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
1047 return Color.TRANSPARENT;
1048 } else {
1049 return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
1050 }
1051 }
1052
1053 @Override
1054 public String getAvatarName() {
1055 return UIHelper.getMessageDisplayName(this);
1056 }
1057
1058 public boolean isOOb() {
1059 return oob || getFileParams().url != null;
1060 }
1061
1062 public Collection<Reaction> getReactions() {
1063 return this.reactions;
1064 }
1065
1066 public void setReactions(Element reactions) {
1067 if (this.payloads != null) {
1068 this.payloads.remove(getReactionsEl());
1069 }
1070 addPayload(reactions);
1071 }
1072
1073 public Element getReactionsEl() {
1074 if (this.payloads == null) return null;
1075
1076 for (Element el : this.payloads) {
1077 if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
1078 return el;
1079 }
1080 }
1081
1082 return null;
1083 }
1084
1085 public boolean isReactionsEmpty() {
1086 return this.reactions.isEmpty();
1087 }
1088
1089 public Reaction.Aggregated getAggregatedReactions() {
1090 return Reaction.aggregated(this.reactions);
1091 }
1092
1093 public void setReactions(final Collection<Reaction> reactions) {
1094 this.reactions = reactions;
1095 }
1096
1097 public static class MergeSeparator {
1098 }
1099
1100 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1101 SpannableStringBuilder spannableBody;
1102 final Element html = getHtml();
1103 if (html == null || Build.VERSION.SDK_INT < 24) {
1104 spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(getInReplyTo() != null)).trim());
1105 spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
1106 } else {
1107 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1108 MessageUtils.filterLtrRtl(html.toString()).trim(),
1109 Html.FROM_HTML_MODE_COMPACT,
1110 (source) -> {
1111 try {
1112 if (thumbnailer == null || source == null) {
1113 return fallbackImg;
1114 }
1115 Cid cid = BobTransfer.cid(new URI(source));
1116 if (cid == null) {
1117 return fallbackImg;
1118 }
1119 Drawable thumbnail = thumbnailer.getThumbnail(cid);
1120 if (thumbnail == null) {
1121 return fallbackImg;
1122 }
1123 return thumbnail;
1124 } catch (final URISyntaxException e) {
1125 return fallbackImg;
1126 }
1127 },
1128 (opening, tag, output, xmlReader) -> {}
1129 ));
1130
1131 // Make images clickable and long-clickable with BetterLinkMovementMethod
1132 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1133 for (ImageSpan span : imageSpans) {
1134 final int start = spannable.getSpanStart(span);
1135 final int end = spannable.getSpanEnd(span);
1136
1137 ClickableSpan click_span = new ClickableSpan() {
1138 @Override
1139 public void onClick(View widget) { }
1140 };
1141
1142 spannable.removeSpan(span);
1143 spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1144 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1145 }
1146
1147 // https://stackoverflow.com/a/10187511/8611
1148 int i = spannable.length();
1149 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1150 spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1151 }
1152
1153 if (getInReplyTo() != null && getModerated() == null) {
1154 // Don't show quote if it's the message right before us
1155 if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
1156
1157 final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1158 if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1159 quote.insert(0, "🖼️");
1160 final var cid = getInReplyTo().getFileParams().getCids().size() < 1 ? null : getInReplyTo().getFileParams().getCids().get(0);
1161 Drawable thumbnail = thumbnailer == null || cid == null ? null : thumbnailer.getThumbnail(cid);
1162 if (thumbnail == null) thumbnail = fallbackImg;
1163 if (thumbnail != null) {
1164 quote.setSpan(new InlineImageSpan(thumbnail, cid == null ? null : cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1165 }
1166 }
1167 quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1168 spannableBody.insert(0, "\n");
1169 spannableBody.insert(0, quote);
1170 }
1171
1172 return spannableBody;
1173 }
1174
1175 public SpannableStringBuilder getMergedBody() {
1176 return getMergedBody(null, null);
1177 }
1178
1179 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1180 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1181 Message current = this;
1182 while (current.mergeable(current.next())) {
1183 current = current.next();
1184 if (current == null || current.getModerated() != null) {
1185 break;
1186 }
1187 body.append("\n\n");
1188 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1189 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1190 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1191 }
1192 return body;
1193 }
1194
1195 public boolean hasMeCommand() {
1196 return this.body.trim().startsWith(ME_COMMAND);
1197 }
1198
1199 public int getMergedStatus() {
1200 int status = this.status;
1201 Message current = this;
1202 while (current.mergeable(current.next())) {
1203 current = current.next();
1204 if (current == null) {
1205 break;
1206 }
1207 status = current.status;
1208 }
1209 return status;
1210 }
1211
1212 public long getMergedTimeSent() {
1213 long time = this.timeSent;
1214 Message current = this;
1215 while (current.mergeable(current.next())) {
1216 current = current.next();
1217 if (current == null) {
1218 break;
1219 }
1220 time = current.timeSent;
1221 }
1222 return time;
1223 }
1224
1225 public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1226 Message prev = this.prev();
1227 if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1228 if (getOccupantId() != null && xmppConnectionService != null) {
1229 final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1230 if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1231 }
1232 return prev != null && prev.mergeable(this);
1233 }
1234
1235 public boolean trusted() {
1236 Contact contact = this.getContact();
1237 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1238 }
1239
1240 public boolean fixCounterpart() {
1241 final Presences presences = conversation.getContact().getPresences();
1242 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1243 return true;
1244 } else if (presences.size() >= 1) {
1245 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1246 return true;
1247 } else {
1248 counterpart = null;
1249 return false;
1250 }
1251 }
1252
1253 public void setUuid(String uuid) {
1254 this.uuid = uuid;
1255 }
1256
1257 public String getEditedId() {
1258 if (edits.size() > 0) {
1259 return edits.get(edits.size() - 1).getEditedId();
1260 } else {
1261 throw new IllegalStateException("Attempting to store unedited message");
1262 }
1263 }
1264
1265 public String getEditedIdWireFormat() {
1266 if (edits.size() > 0) {
1267 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1268 } else {
1269 throw new IllegalStateException("Attempting to store unedited message");
1270 }
1271 }
1272
1273 public List<URI> getLinks() {
1274 SpannableStringBuilder text = new SpannableStringBuilder(
1275 getBody().replaceAll("^>.*", "") // Remove quotes
1276 );
1277 return MyLinkify.extractLinks(text).stream().map((url) -> {
1278 try {
1279 return new URI(url);
1280 } catch (final URISyntaxException e) {
1281 return null;
1282 }
1283 }).filter(x -> x != null).collect(Collectors.toList());
1284 }
1285
1286 public URI getOob() {
1287 final String url = getFileParams().url;
1288 try {
1289 return url == null ? null : new URI(url);
1290 } catch (final URISyntaxException e) {
1291 return null;
1292 }
1293 }
1294
1295 public void clearPayloads() {
1296 this.payloads.clear();
1297 }
1298
1299 public void addPayload(Element el) {
1300 if (el == null) return;
1301
1302 this.payloads.add(el);
1303 }
1304
1305 public List<Element> getPayloads() {
1306 return new ArrayList<>(this.payloads);
1307 }
1308
1309 public List<Element> getFallbacks(String... includeFor) {
1310 List<Element> fallbacks = new ArrayList<>();
1311
1312 if (this.payloads == null) return fallbacks;
1313
1314 for (Element el : this.payloads) {
1315 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1316 final String fallbackFor = el.getAttribute("for");
1317 if (fallbackFor == null) continue;
1318 for (String includeOne : includeFor) {
1319 if (fallbackFor.equals(includeOne)) {
1320 fallbacks.add(el);
1321 break;
1322 }
1323 }
1324 }
1325 }
1326
1327 return fallbacks;
1328 }
1329
1330 public Element getHtml() {
1331 return getHtml(false);
1332 }
1333
1334 public Element getHtml(boolean root) {
1335 if (this.payloads == null) return null;
1336
1337 for (Element el : this.payloads) {
1338 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1339 return root ? el : el.getChildren().get(0);
1340 }
1341 }
1342
1343 return null;
1344 }
1345
1346 public List<Element> getCommands() {
1347 if (this.payloads == null) return null;
1348
1349 for (Element el : this.payloads) {
1350 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1351 return el.getChildren();
1352 }
1353 }
1354
1355 return null;
1356 }
1357
1358 public List<Element> getLinkDescriptions() {
1359 final ArrayList<Element> result = new ArrayList<>();
1360 if (this.payloads == null) return result;
1361
1362 for (Element el : this.payloads) {
1363 if (el.getName().equals("Description") && el.getNamespace().equals("http://www.w3.org/1999/02/22-rdf-syntax-ns#")) {
1364 result.add(el);
1365 }
1366 }
1367
1368 return result;
1369 }
1370
1371 public String getMimeType() {
1372 String extension;
1373 if (relativeFilePath != null) {
1374 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1375 } else {
1376 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1377 if (url == null) {
1378 return null;
1379 }
1380 extension = MimeUtils.extractRelevantExtension(url);
1381 }
1382 return MimeUtils.guessMimeTypeFromExtension(extension);
1383 }
1384
1385 public synchronized boolean treatAsDownloadable() {
1386 if (treatAsDownloadable == null) {
1387 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1388 }
1389 return treatAsDownloadable;
1390 }
1391
1392 public synchronized boolean hasCustomEmoji() {
1393 if (getHtml() != null) {
1394 SpannableStringBuilder spannable = getSpannableBody(null, null);
1395 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1396 return imageSpans.length > 0;
1397 }
1398
1399 return false;
1400 }
1401
1402 public synchronized boolean bodyIsOnlyEmojis() {
1403 if (isEmojisOnly == null) {
1404 isEmojisOnly = Emoticons.isOnlyEmoji(getBody());
1405 if (isEmojisOnly) return true;
1406
1407 if (getHtml() != null) {
1408 SpannableStringBuilder spannable = getSpannableBody(null, null);
1409 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1410 for (ImageSpan span : imageSpans) {
1411 final int start = spannable.getSpanStart(span);
1412 final int end = spannable.getSpanEnd(span);
1413 spannable.delete(start, end);
1414 }
1415 final String after = spannable.toString().replaceAll("\\s", "");
1416 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1417 }
1418 }
1419 return isEmojisOnly;
1420 }
1421
1422 public synchronized boolean isGeoUri() {
1423 if (isGeoUri == null) {
1424 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1425 }
1426 return isGeoUri;
1427 }
1428
1429 protected List<Element> getSims() {
1430 return payloads.stream().filter(el ->
1431 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1432 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1433 ).collect(Collectors.toList());
1434 }
1435
1436 public synchronized void resetFileParams() {
1437 this.oob = false;
1438 this.fileParams = null;
1439 this.transferable = null;
1440 this.payloads.removeAll(getSims());
1441 clearFallbacks(Namespace.OOB);
1442 setType(isPrivateMessage() ? TYPE_PRIVATE : TYPE_TEXT);
1443 }
1444
1445 public synchronized void setFileParams(FileParams fileParams) {
1446 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1447 fileParams.sims = this.fileParams.sims;
1448 }
1449 this.fileParams = fileParams;
1450 if (fileParams != null && getSims().isEmpty()) {
1451 addPayload(fileParams.toSims());
1452 }
1453 }
1454
1455 public synchronized FileParams getFileParams() {
1456 if (fileParams == null) {
1457 List<Element> sims = getSims();
1458 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1459 if (this.transferable != null) {
1460 fileParams.size = this.transferable.getFileSize();
1461 }
1462 }
1463
1464 return fileParams;
1465 }
1466
1467 private static int parseInt(String value) {
1468 try {
1469 return Integer.parseInt(value);
1470 } catch (NumberFormatException e) {
1471 return 0;
1472 }
1473 }
1474
1475 public void untie() {
1476 this.mNextMessage = null;
1477 this.mPreviousMessage = null;
1478 }
1479
1480 public boolean isPrivateMessage() {
1481 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1482 }
1483
1484 public boolean isFileOrImage() {
1485 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1486 }
1487
1488
1489 public boolean isTypeText() {
1490 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1491 }
1492
1493 public boolean hasFileOnRemoteHost() {
1494 return isFileOrImage() && getFileParams().url != null;
1495 }
1496
1497 public boolean needsUploading() {
1498 return isFileOrImage() && getFileParams().url == null;
1499 }
1500
1501 public static class FileParams {
1502 public String url;
1503 public Long size = null;
1504 public int width = 0;
1505 public int height = 0;
1506 public int runtime = 0;
1507 public Element sims = null;
1508
1509 public FileParams() { }
1510
1511 public FileParams(Element el) {
1512 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1513 this.url = el.findChildContent("url", Namespace.OOB);
1514 }
1515 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1516 sims = el;
1517 final String refUri = el.getAttribute("uri");
1518 if (refUri != null) url = refUri;
1519 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1520 if (mediaSharing != null) {
1521 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1522 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1523 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1524 if (file != null) {
1525 try {
1526 String sizeS = file.findChildContent("size", file.getNamespace());
1527 if (sizeS != null) size = new Long(sizeS);
1528 String widthS = file.findChildContent("width", "https://schema.org/");
1529 if (widthS != null) width = parseInt(widthS);
1530 String heightS = file.findChildContent("height", "https://schema.org/");
1531 if (heightS != null) height = parseInt(heightS);
1532 String durationS = file.findChildContent("duration", "https://schema.org/");
1533 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1534 } catch (final NumberFormatException e) {
1535 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1536 }
1537 }
1538
1539 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1540 if (sources != null) {
1541 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1542 if (ref != null) url = ref.getAttribute("uri");
1543 }
1544 }
1545 }
1546 }
1547
1548 public FileParams(String ser) {
1549 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1550 switch (parts.length) {
1551 case 1:
1552 try {
1553 this.size = Long.parseLong(parts[0]);
1554 } catch (final NumberFormatException e) {
1555 this.url = URL.tryParse(parts[0]);
1556 }
1557 break;
1558 case 5:
1559 this.runtime = parseInt(parts[4]);
1560 case 4:
1561 this.width = parseInt(parts[2]);
1562 this.height = parseInt(parts[3]);
1563 case 2:
1564 this.url = URL.tryParse(parts[0]);
1565 this.size = Longs.tryParse(parts[1]);
1566 break;
1567 case 3:
1568 this.size = Longs.tryParse(parts[0]);
1569 this.width = parseInt(parts[1]);
1570 this.height = parseInt(parts[2]);
1571 break;
1572 }
1573 }
1574
1575 public boolean isEmpty() {
1576 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1577 }
1578
1579 public long getSize() {
1580 return size == null ? 0 : size;
1581 }
1582
1583 public String getName() {
1584 Element file = getFileElement();
1585 if (file == null) return null;
1586
1587 return file.findChildContent("name", file.getNamespace());
1588 }
1589
1590 public void setName(final String name) {
1591 if (sims == null) toSims();
1592 Element file = getFileElement();
1593
1594 for (Element child : file.getChildren()) {
1595 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1596 file.removeChild(child);
1597 }
1598 }
1599
1600 if (name != null) {
1601 file.addChild("name", file.getNamespace()).setContent(name);
1602 }
1603 }
1604
1605 public String getMediaType() {
1606 Element file = getFileElement();
1607 if (file == null) return null;
1608
1609 return file.findChildContent("media-type", file.getNamespace());
1610 }
1611
1612 public void setMediaType(final String mime) {
1613 if (sims == null) toSims();
1614 Element file = getFileElement();
1615
1616 for (Element child : file.getChildren()) {
1617 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1618 file.removeChild(child);
1619 }
1620 }
1621
1622 if (mime != null) {
1623 file.addChild("media-type", file.getNamespace()).setContent(mime);
1624 }
1625 }
1626
1627 public Element toSims() {
1628 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1629 sims.setAttribute("type", "data");
1630 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1631 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1632
1633 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1634 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1635 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1636 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1637
1638 file.removeChild(file.findChild("size", file.getNamespace()));
1639 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1640
1641 file.removeChild(file.findChild("width", "https://schema.org/"));
1642 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1643
1644 file.removeChild(file.findChild("height", "https://schema.org/"));
1645 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1646
1647 file.removeChild(file.findChild("duration", "https://schema.org/"));
1648 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1649
1650 if (url != null) {
1651 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1652 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1653
1654 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1655 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1656 source.setAttribute("type", "data");
1657 source.setAttribute("uri", url);
1658 }
1659
1660 return sims;
1661 }
1662
1663 protected Element getFileElement() {
1664 Element file = null;
1665 if (sims == null) return file;
1666
1667 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1668 if (mediaSharing == null) return file;
1669 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1670 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1671 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1672 return file;
1673 }
1674
1675 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1676 if (sims == null) toSims();
1677 Element file = getFileElement();
1678
1679 for (Element child : file.getChildren()) {
1680 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1681 file.removeChild(child);
1682 }
1683 }
1684
1685 for (Cid cid : cids) {
1686 file.addChild("hash", "urn:xmpp:hashes:2")
1687 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1688 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1689 }
1690 }
1691
1692 public List<Cid> getCids() {
1693 List<Cid> cids = new ArrayList<>();
1694 Element file = getFileElement();
1695 if (file == null) return cids;
1696
1697 for (Element child : file.getChildren()) {
1698 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1699 try {
1700 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1701 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1702 }
1703 }
1704
1705 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1706
1707 return cids;
1708 }
1709
1710 public void addThumbnail(int width, int height, String mimeType, String uri) {
1711 for (Element thumb : getThumbnails()) {
1712 if (uri.equals(thumb.getAttribute("uri"))) return;
1713 }
1714
1715 if (sims == null) toSims();
1716 Element file = getFileElement();
1717 file.addChild(
1718 new Element("thumbnail", "urn:xmpp:thumbs:1")
1719 .setAttribute("width", Integer.toString(width))
1720 .setAttribute("height", Integer.toString(height))
1721 .setAttribute("type", mimeType)
1722 .setAttribute("uri", uri)
1723 );
1724 }
1725
1726 public List<Element> getThumbnails() {
1727 List<Element> thumbs = new ArrayList<>();
1728 Element file = getFileElement();
1729 if (file == null) return thumbs;
1730
1731 for (Element child : file.getChildren()) {
1732 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1733 thumbs.add(child);
1734 }
1735 }
1736
1737 return thumbs;
1738 }
1739
1740 public String toString() {
1741 final StringBuilder builder = new StringBuilder();
1742 if (url != null) builder.append(url);
1743 if (size != null) builder.append('|').append(size.toString());
1744 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1745 if (height > 0 || runtime > 0) builder.append('|').append(height);
1746 if (runtime > 0) builder.append('|').append(runtime);
1747 return builder.toString();
1748 }
1749
1750 public boolean equals(Object o) {
1751 if (!(o instanceof FileParams)) return false;
1752 if (url == null) return false;
1753
1754 return url.equals(((FileParams) o).url);
1755 }
1756
1757 public int hashCode() {
1758 return url == null ? super.hashCode() : url.hashCode();
1759 }
1760 }
1761
1762 public void setFingerprint(String fingerprint) {
1763 this.axolotlFingerprint = fingerprint;
1764 }
1765
1766 public String getFingerprint() {
1767 return axolotlFingerprint;
1768 }
1769
1770 public boolean isTrusted() {
1771 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1772 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1773 return s != null && s.isTrusted();
1774 }
1775
1776 private int getPreviousEncryption() {
1777 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1778 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1779 continue;
1780 }
1781 return iterator.getEncryption();
1782 }
1783 return ENCRYPTION_NONE;
1784 }
1785
1786 private int getNextEncryption() {
1787 if (this.conversation instanceof Conversation) {
1788 Conversation conversation = (Conversation) this.conversation;
1789 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1790 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1791 continue;
1792 }
1793 return iterator.getEncryption();
1794 }
1795 return conversation.getNextEncryption();
1796 } else {
1797 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1798 }
1799 }
1800
1801 public boolean isValidInSession() {
1802 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1803 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1804
1805 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1806 || futureEncryption == ENCRYPTION_NONE
1807 || pastEncryption != futureEncryption;
1808
1809 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1810 }
1811
1812 private static int getCleanedEncryption(int encryption) {
1813 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1814 return ENCRYPTION_PGP;
1815 }
1816 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1817 return ENCRYPTION_AXOLOTL;
1818 }
1819 return encryption;
1820 }
1821
1822 public static boolean configurePrivateMessage(final Message message) {
1823 return configurePrivateMessage(message, false);
1824 }
1825
1826 public static boolean configurePrivateFileMessage(final Message message) {
1827 return configurePrivateMessage(message, true);
1828 }
1829
1830 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1831 final Conversation conversation;
1832 if (message.conversation instanceof Conversation) {
1833 conversation = (Conversation) message.conversation;
1834 } else {
1835 return false;
1836 }
1837 if (conversation.getMode() == Conversation.MODE_MULTI) {
1838 final Jid nextCounterpart = conversation.getNextCounterpart();
1839 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1840 }
1841 return false;
1842 }
1843
1844 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1845 final Conversation conversation;
1846 if (message.conversation instanceof Conversation) {
1847 conversation = (Conversation) message.conversation;
1848 } else {
1849 return false;
1850 }
1851 return configurePrivateMessage(conversation, message, counterpart, false);
1852 }
1853
1854 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1855 if (counterpart == null) {
1856 return false;
1857 }
1858 message.setCounterpart(counterpart);
1859 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1860 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1861 return true;
1862 }
1863
1864 public static class PlainTextSpan {}
1865}