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 mInReplyTo = null;
435 this.payloads.remove(getReactionsEl());
436 this.payloads.remove(getReply());
437 clearFallbacks("urn:xmpp:reply:0", "urn:xmpp:reactions:0");
438 }
439
440 public void updateReplyTo(final Message replyTo, Spanned body) {
441 clearReplyReact();
442
443 if (body == null) body = new SpannableStringBuilder(getBody(true));
444 setBody(QuoteHelper.quote(MessageUtils.prepareQuote(replyTo)) + "\n");
445
446 final String replyId = replyTo.replyId();
447 if (replyId == null) return;
448
449 addPayload(
450 new Element("reply", "urn:xmpp:reply:0")
451 .setAttribute("to", replyTo.getCounterpart())
452 .setAttribute("id", replyId)
453 );
454 final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0");
455 fallback.addChild("body", "urn:xmpp:fallback:0")
456 .setAttribute("start", "0")
457 .setAttribute("end", "" + this.body.codePointCount(0, this.body.length()));
458 addPayload(fallback);
459
460 appendBody(body);
461 setInReplyTo(replyTo);
462 }
463
464 public void updateReaction(final Message reactTo, String emoji) {
465 Set<String> emojis = new HashSet<>();
466 if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(reactTo.replyId(), null);
467 emojis.remove(getBody(true));
468 emojis.add(emoji);
469
470 updateReplyTo(reactTo, new SpannableStringBuilder(emoji));
471 final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
472 fallback.addChild("body", "urn:xmpp:fallback:0");
473 addPayload(fallback);
474 final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", reactTo.replyId());
475 for (String oneEmoji : emojis) {
476 reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji);
477 }
478 addPayload(reactions);
479 }
480
481 public Element getReply() {
482 if (this.payloads == null) return null;
483
484 for (Element el : this.payloads) {
485 if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) {
486 return el;
487 }
488 }
489
490 return null;
491 }
492
493 public boolean isAttention() {
494 if (this.payloads == null) return false;
495
496 for (Element el : this.payloads) {
497 if (el.getName().equals("attention") && el.getNamespace().equals("urn:xmpp:attention:0")) {
498 return true;
499 }
500 }
501
502 return false;
503 }
504
505 public String getConversationUuid() {
506 return conversationUuid;
507 }
508
509 public Conversational getConversation() {
510 return this.conversation;
511 }
512
513 public Jid getCounterpart() {
514 return counterpart;
515 }
516
517 public void setCounterpart(final Jid counterpart) {
518 this.counterpart = counterpart;
519 }
520
521 public Contact getContact() {
522 if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
523 if (this.trueCounterpart != null) {
524 return this.conversation.getAccount().getRoster()
525 .getContact(this.trueCounterpart);
526 }
527
528 return this.conversation.getContact();
529 } else {
530 if (this.trueCounterpart == null) {
531 return null;
532 } else {
533 return this.conversation.getAccount().getRoster()
534 .getContactFromContactList(this.trueCounterpart);
535 }
536 }
537 }
538
539 public String getQuoteableBody() {
540 if (this.body == null) return null;
541
542 StringBuilder body = bodyMinusFallbacks("http://jabber.org/protocol/address").first;
543 return body.toString();
544 }
545
546 public String getRawBody() {
547 return this.body;
548 }
549
550 private Pair<StringBuilder, Boolean> bodyMinusFallbacks(String... fallbackNames) {
551 StringBuilder body = new StringBuilder(this.body == null ? "" : this.body);
552
553 List<Element> fallbacks = getFallbacks(fallbackNames);
554 List<Pair<Integer, Integer>> spans = new ArrayList<>();
555 for (Element fallback : fallbacks) {
556 for (Element span : fallback.getChildren()) {
557 if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
558 if (span.getAttribute("start") == null || span.getAttribute("end") == null) return new Pair<>(new StringBuilder(""), true);
559 spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
560 }
561 }
562 // Do them in reverse order so that span deletions don't affect the indexes of other spans
563 spans.sort((x, y) -> y.first.compareTo(x.first));
564 try {
565 for (Pair<Integer, Integer> span : spans) {
566 body.delete(body.offsetByCodePoints(0, span.first.intValue()), body.offsetByCodePoints(0, span.second.intValue()));
567 }
568 } catch (final IndexOutOfBoundsException e) { spans.clear(); }
569
570 return new Pair<>(body, !spans.isEmpty());
571 }
572
573 public String getBody() {
574 return getBody(false);
575 }
576
577 public String getBody(final boolean removeQuoteFallbacks) {
578 if (body == null) return "";
579
580 List<String> fallbacksToRemove = new ArrayList<>();
581 fallbacksToRemove.add("http://jabber.org/protocol/address");
582 if (getOob() != null || isGeoUri()) fallbacksToRemove.add(Namespace.OOB);
583 if (removeQuoteFallbacks) fallbacksToRemove.add("urn:xmpp:reply:0");
584 Pair<StringBuilder, Boolean> result = bodyMinusFallbacks(fallbacksToRemove.toArray(new String[0]));
585 StringBuilder body = result.first;
586
587 final String aesgcm = MessageUtils.aesgcmDownloadable(body.toString());
588 if (!result.second && aesgcm != null) {
589 return body.toString().replace(aesgcm, "");
590 } else if (!result.second && getOob() != null) {
591 return body.toString().replace(getOob().toString(), "");
592 } else if (!result.second && isGeoUri()) {
593 return "";
594 } else {
595 return body.toString();
596 }
597 }
598
599 public synchronized void clearFallbacks(String... includeFor) {
600 this.payloads.removeAll(getFallbacks(includeFor));
601 }
602
603 public synchronized Element getOrMakeHtml() {
604 Element html = getHtml();
605 if (html != null) return html;
606 html = new Element("html", "http://jabber.org/protocol/xhtml-im");
607 Element body = html.addChild("body", "http://www.w3.org/1999/xhtml");
608 SpannedToXHTML.append(body, new SpannableStringBuilder(getBody(true)));
609 addPayload(html);
610 return body;
611 }
612
613 public synchronized void setBody(Spanned span) {
614 // Don't bother removing, we'll edit below
615 setBodyPreserveXHTML(span == null ? null : span.toString());
616 if (span == null || SpannedToXHTML.isPlainText(span)) {
617 this.payloads.remove(getHtml(true));
618 } else {
619 final Element body = getOrMakeHtml();
620 body.clearChildren();
621 SpannedToXHTML.append(body, span);
622 }
623 }
624
625 public synchronized void setHtml(Element html) {
626 final Element oldHtml = getHtml(true);
627 if (oldHtml != null) this.payloads.remove(oldHtml);
628 if (html != null) addPayload(html);
629 }
630
631 private synchronized void setBodyPreserveXHTML(String body) {
632 this.body = body;
633 this.isGeoUri = null;
634 this.isEmojisOnly = null;
635 this.treatAsDownloadable = null;
636 }
637
638 public synchronized void setBody(String body) {
639 setBodyPreserveXHTML(body);
640 this.payloads.remove(getHtml(true));
641 }
642
643 public synchronized void appendBody(Spanned append) {
644 if (!SpannedToXHTML.isPlainText(append) || getHtml() != null) {
645 final Element body = getOrMakeHtml();
646 SpannedToXHTML.append(body, append);
647 }
648 appendBody(append.toString());
649 }
650
651 public synchronized void appendBody(String append) {
652 this.body += append;
653 this.isGeoUri = null;
654 this.isEmojisOnly = null;
655 this.treatAsDownloadable = null;
656 }
657
658 public String getSubject() {
659 return subject;
660 }
661
662 public synchronized void setSubject(String subject) {
663 this.subject = subject;
664 }
665
666 public Element getThread() {
667 if (this.payloads == null) return null;
668
669 for (Element el : this.payloads) {
670 if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
671 return el;
672 }
673 }
674
675 return null;
676 }
677
678 public void setThread(Element thread) {
679 payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
680 addPayload(thread);
681 }
682
683 public void setOccupantId(final String id) {
684 occupantId = id;
685 }
686
687 public String getOccupantId() {
688 return occupantId;
689 }
690
691 public void setMucUser(MucOptions.User user) {
692 this.user = new WeakReference<>(user);
693 if (user != null && user.getOccupantId() != null) setOccupantId(user.getOccupantId());
694 }
695
696 public boolean sameMucUser(Message otherMessage) {
697 final MucOptions.User thisUser = this.user == null ? null : this.user.get();
698 final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
699 return
700 (thisUser != null && thisUser == otherUser) ||
701 (getOccupantId() != null && getOccupantId().equals(otherMessage.getOccupantId()));
702 }
703
704 public String getErrorMessage() {
705 return errorMessage;
706 }
707
708 public boolean setErrorMessage(String message) {
709 boolean changed = (message != null && !message.equals(errorMessage))
710 || (message == null && errorMessage != null);
711 this.errorMessage = message;
712 return changed;
713 }
714
715 public long getTimeReceived() {
716 return timeReceived;
717 }
718
719 public long getTimeSent() {
720 return timeSent;
721 }
722
723 public int getEncryption() {
724 return encryption;
725 }
726
727 public void setEncryption(int encryption) {
728 this.encryption = encryption;
729 }
730
731 public int getStatus() {
732 return status;
733 }
734
735 public void setStatus(int status) {
736 this.status = status;
737 }
738
739 public String getRelativeFilePath() {
740 return this.relativeFilePath;
741 }
742
743 public void setRelativeFilePath(String path) {
744 this.relativeFilePath = path;
745 }
746
747 public String getRemoteMsgId() {
748 return this.remoteMsgId;
749 }
750
751 public void setRemoteMsgId(String id) {
752 this.remoteMsgId = id;
753 }
754
755 public String getServerMsgId() {
756 return this.serverMsgId;
757 }
758
759 public void setServerMsgId(String id) {
760 this.serverMsgId = id;
761 }
762
763 public boolean isRead() {
764 return this.read;
765 }
766
767 public boolean isDeleted() {
768 return this.deleted;
769 }
770
771 public Element getModerated() {
772 if (this.payloads == null) return null;
773
774 for (Element el : this.payloads) {
775 if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
776 return el;
777 }
778 }
779
780 return null;
781 }
782
783 public void setDeleted(boolean deleted) {
784 this.deleted = deleted;
785 }
786
787 public void markRead() {
788 this.read = true;
789 }
790
791 public void markUnread() {
792 this.read = false;
793 }
794
795 public void setTime(long time) {
796 this.timeSent = time;
797 }
798
799 public void setTimeReceived(long time) {
800 this.timeReceived = time;
801 }
802
803 public String getEncryptedBody() {
804 return this.encryptedBody;
805 }
806
807 public void setEncryptedBody(String body) {
808 this.encryptedBody = body;
809 }
810
811 public int getType() {
812 return this.type;
813 }
814
815 public void setType(int type) {
816 this.type = type;
817 }
818
819 public boolean isCarbon() {
820 return carbon;
821 }
822
823 public void setCarbon(boolean carbon) {
824 this.carbon = carbon;
825 }
826
827 public void putEdited(String edited, String serverMsgId) {
828 final Edit edit = new Edit(edited, serverMsgId);
829 if (this.edits.size() < 128 && !this.edits.contains(edit)) {
830 this.edits.add(edit);
831 }
832 }
833
834 boolean remoteMsgIdMatchInEdit(String id) {
835 for (Edit edit : this.edits) {
836 if (id.equals(edit.getEditedId())) {
837 return true;
838 }
839 }
840 return false;
841 }
842
843 public String getBodyLanguage() {
844 return this.bodyLanguage;
845 }
846
847 public void setBodyLanguage(String language) {
848 this.bodyLanguage = language;
849 }
850
851 public boolean edited() {
852 return this.edits.size() > 0;
853 }
854
855 public void setTrueCounterpart(Jid trueCounterpart) {
856 this.trueCounterpart = trueCounterpart;
857 }
858
859 public Jid getTrueCounterpart() {
860 return this.trueCounterpart;
861 }
862
863 public Transferable getTransferable() {
864 return this.transferable;
865 }
866
867 public synchronized void setTransferable(Transferable transferable) {
868 this.transferable = transferable;
869 }
870
871 public boolean addReadByMarker(final ReadByMarker readByMarker) {
872 if (readByMarker.getRealJid() != null) {
873 if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
874 return false;
875 }
876 } else if (readByMarker.getFullJid() != null) {
877 if (readByMarker.getFullJid().equals(counterpart)) {
878 return false;
879 }
880 }
881 if (this.readByMarkers.add(readByMarker)) {
882 if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
883 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
884 while (iterator.hasNext()) {
885 ReadByMarker marker = iterator.next();
886 if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
887 iterator.remove();
888 }
889 }
890 }
891 return true;
892 } else {
893 return false;
894 }
895 }
896
897 public Set<ReadByMarker> getReadByMarkers() {
898 return ImmutableSet.copyOf(this.readByMarkers);
899 }
900
901 public Set<Jid> getReadyByTrue() {
902 return ImmutableSet.copyOf(
903 Collections2.transform(
904 Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
905 ReadByMarker::getRealJid));
906 }
907
908 public void setInReplyTo(final Message m) {
909 mInReplyTo = m;
910 }
911
912 public Message getInReplyTo() {
913 return mInReplyTo;
914 }
915
916 boolean similar(Message message) {
917 if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
918 return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
919 } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
920 return true;
921 } else if (this.body == null || this.counterpart == null) {
922 return false;
923 } else {
924 String body, otherBody;
925 if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
926 body = getFileParams().url;
927 otherBody = message.body == null ? null : message.body.trim();
928 } else {
929 body = this.body;
930 otherBody = message.body;
931 }
932 final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
933 if (message.getRemoteMsgId() != null) {
934 final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
935 if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
936 return true;
937 }
938 return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
939 && matchingCounterpart
940 && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
941 } else {
942 return this.remoteMsgId == null
943 && matchingCounterpart
944 && body.equals(otherBody)
945 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
946 }
947 }
948 }
949
950 public Message next() {
951 if (this.conversation instanceof Conversation) {
952 final Conversation conversation = (Conversation) this.conversation;
953 synchronized (conversation.messages) {
954 if (this.mNextMessage == null) {
955 int index = conversation.messages.indexOf(this);
956 if (index < 0 || index >= conversation.messages.size() - 1) {
957 this.mNextMessage = null;
958 } else {
959 this.mNextMessage = conversation.messages.get(index + 1);
960 }
961 }
962 return this.mNextMessage;
963 }
964 } else {
965 throw new AssertionError("Calling next should be disabled for stubs");
966 }
967 }
968
969 public Message prev() {
970 if (this.conversation instanceof Conversation) {
971 final Conversation conversation = (Conversation) this.conversation;
972 synchronized (conversation.messages) {
973 if (this.mPreviousMessage == null) {
974 int index = conversation.messages.indexOf(this);
975 if (index <= 0 || index > conversation.messages.size()) {
976 this.mPreviousMessage = null;
977 } else {
978 this.mPreviousMessage = conversation.messages.get(index - 1);
979 }
980 }
981 }
982 return this.mPreviousMessage;
983 } else {
984 throw new AssertionError("Calling prev should be disabled for stubs");
985 }
986 }
987
988 public boolean isLastCorrectableMessage() {
989 Message next = next();
990 while (next != null) {
991 if (next.isEditable()) {
992 return false;
993 }
994 next = next.next();
995 }
996 return isEditable();
997 }
998
999 public boolean isEditable() {
1000 return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
1001 }
1002
1003 public boolean mergeable(final Message message) {
1004 return false; // Merging messages messes up reply, so disable for now
1005 }
1006
1007 private static boolean isStatusMergeable(int a, int b) {
1008 return a == b || (
1009 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
1010 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
1011 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
1012 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
1013 || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
1014 );
1015 }
1016
1017 private static boolean isEncryptionMergeable(final int a, final int b) {
1018 return a == b
1019 && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
1020 .contains(a);
1021 }
1022
1023 public void setCounterparts(List<MucOptions.User> counterparts) {
1024 this.counterparts = counterparts;
1025 }
1026
1027 public List<MucOptions.User> getCounterparts() {
1028 return this.counterparts;
1029 }
1030
1031 @Override
1032 public int getAvatarBackgroundColor() {
1033 if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
1034 return Color.TRANSPARENT;
1035 } else {
1036 return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
1037 }
1038 }
1039
1040 @Override
1041 public String getAvatarName() {
1042 return UIHelper.getMessageDisplayName(this);
1043 }
1044
1045 public boolean isOOb() {
1046 return oob || getFileParams().url != null;
1047 }
1048
1049 public Collection<Reaction> getReactions() {
1050 return this.reactions;
1051 }
1052
1053 public void setReactions(Element reactions) {
1054 if (this.payloads != null) {
1055 this.payloads.remove(getReactionsEl());
1056 }
1057 addPayload(reactions);
1058 }
1059
1060 public Element getReactionsEl() {
1061 if (this.payloads == null) return null;
1062
1063 for (Element el : this.payloads) {
1064 if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
1065 return el;
1066 }
1067 }
1068
1069 return null;
1070 }
1071
1072 public boolean isReactionsEmpty() {
1073 return this.reactions.isEmpty();
1074 }
1075
1076 public Reaction.Aggregated getAggregatedReactions() {
1077 return Reaction.aggregated(this.reactions);
1078 }
1079
1080 public void setReactions(final Collection<Reaction> reactions) {
1081 this.reactions = reactions;
1082 }
1083
1084 public static class MergeSeparator {
1085 }
1086
1087 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1088 return getSpannableBody(thumbnailer, fallbackImg, true);
1089 }
1090
1091 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg, final boolean includeReplyTo) {
1092 SpannableStringBuilder spannableBody;
1093 final Element html = getHtml();
1094 if (html == null || Build.VERSION.SDK_INT < 24) {
1095 spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(includeReplyTo && getInReplyTo() != null)).trim());
1096 spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
1097 } else {
1098 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1099 MessageUtils.filterLtrRtl(html.toString()).trim(),
1100 Html.FROM_HTML_MODE_COMPACT,
1101 (source) -> {
1102 try {
1103 if (thumbnailer == null || source == null) {
1104 return fallbackImg;
1105 }
1106 Cid cid = BobTransfer.cid(new URI(source));
1107 if (cid == null) {
1108 return fallbackImg;
1109 }
1110 Drawable thumbnail = thumbnailer.getThumbnail(cid);
1111 if (thumbnail == null) {
1112 return fallbackImg;
1113 }
1114 return thumbnail;
1115 } catch (final URISyntaxException e) {
1116 return fallbackImg;
1117 }
1118 },
1119 (opening, tag, output, xmlReader) -> {}
1120 ));
1121
1122 // Make images clickable and long-clickable with BetterLinkMovementMethod
1123 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1124 for (ImageSpan span : imageSpans) {
1125 final int start = spannable.getSpanStart(span);
1126 final int end = spannable.getSpanEnd(span);
1127
1128 ClickableSpan click_span = new ClickableSpan() {
1129 @Override
1130 public void onClick(View widget) { }
1131 };
1132
1133 spannable.removeSpan(span);
1134 spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1135 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1136 }
1137
1138 // https://stackoverflow.com/a/10187511/8611
1139 int i = spannable.length();
1140 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1141 spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1142 }
1143
1144 if (includeReplyTo && getInReplyTo() != null && getModerated() == null) {
1145 // Don't show quote if it's the message right before us
1146 if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
1147
1148 final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1149 if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1150 quote.insert(0, "🖼️");
1151 final var cid = getInReplyTo().getFileParams().getCids().size() < 1 ? null : getInReplyTo().getFileParams().getCids().get(0);
1152 Drawable thumbnail = thumbnailer == null || cid == null ? null : thumbnailer.getThumbnail(cid);
1153 if (thumbnail == null) thumbnail = fallbackImg;
1154 if (thumbnail != null) {
1155 quote.setSpan(new InlineImageSpan(thumbnail, cid == null ? null : cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1156 }
1157 }
1158 quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1159 spannableBody.insert(0, "\n");
1160 spannableBody.insert(0, quote);
1161 }
1162
1163 return spannableBody;
1164 }
1165
1166 public SpannableStringBuilder getMergedBody() {
1167 return getMergedBody(null, null);
1168 }
1169
1170 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1171 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1172 Message current = this;
1173 while (current.mergeable(current.next())) {
1174 current = current.next();
1175 if (current == null || current.getModerated() != null) {
1176 break;
1177 }
1178 body.append("\n\n");
1179 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1180 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1181 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1182 }
1183 return body;
1184 }
1185
1186 public boolean hasMeCommand() {
1187 return this.body.trim().startsWith(ME_COMMAND);
1188 }
1189
1190 public int getMergedStatus() {
1191 int status = this.status;
1192 Message current = this;
1193 while (current.mergeable(current.next())) {
1194 current = current.next();
1195 if (current == null) {
1196 break;
1197 }
1198 status = current.status;
1199 }
1200 return status;
1201 }
1202
1203 public long getMergedTimeSent() {
1204 long time = this.timeSent;
1205 Message current = this;
1206 while (current.mergeable(current.next())) {
1207 current = current.next();
1208 if (current == null) {
1209 break;
1210 }
1211 time = current.timeSent;
1212 }
1213 return time;
1214 }
1215
1216 public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1217 Message prev = this.prev();
1218 if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1219 if (getOccupantId() != null && xmppConnectionService != null) {
1220 final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1221 if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1222 }
1223 return prev != null && prev.mergeable(this);
1224 }
1225
1226 public boolean trusted() {
1227 Contact contact = this.getContact();
1228 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1229 }
1230
1231 public boolean fixCounterpart() {
1232 final Presences presences = conversation.getContact().getPresences();
1233 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1234 return true;
1235 } else if (presences.size() >= 1) {
1236 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1237 return true;
1238 } else {
1239 counterpart = null;
1240 return false;
1241 }
1242 }
1243
1244 public void setUuid(String uuid) {
1245 this.uuid = uuid;
1246 }
1247
1248 public String getEditedId() {
1249 if (edits.size() > 0) {
1250 return edits.get(edits.size() - 1).getEditedId();
1251 } else {
1252 throw new IllegalStateException("Attempting to store unedited message");
1253 }
1254 }
1255
1256 public String getEditedIdWireFormat() {
1257 if (edits.size() > 0) {
1258 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1259 } else {
1260 throw new IllegalStateException("Attempting to store unedited message");
1261 }
1262 }
1263
1264 public List<URI> getLinks() {
1265 SpannableStringBuilder text = new SpannableStringBuilder(
1266 getBody(true).replaceAll("^>.*", "") // Remove quotes
1267 );
1268 return MyLinkify.extractLinks(text).stream().map((url) -> {
1269 try {
1270 return new URI(url);
1271 } catch (final URISyntaxException e) {
1272 return null;
1273 }
1274 }).filter(x -> x != null).collect(Collectors.toList());
1275 }
1276
1277 public URI getOob() {
1278 final String url = getFileParams().url;
1279 try {
1280 return url == null ? null : new URI(url);
1281 } catch (final URISyntaxException e) {
1282 return null;
1283 }
1284 }
1285
1286 public void clearPayloads() {
1287 this.payloads.clear();
1288 }
1289
1290 public void addPayload(Element el) {
1291 if (el == null) return;
1292
1293 this.payloads.add(el);
1294 }
1295
1296 public List<Element> getPayloads() {
1297 return new ArrayList<>(this.payloads);
1298 }
1299
1300 public List<Element> getFallbacks(String... includeFor) {
1301 List<Element> fallbacks = new ArrayList<>();
1302
1303 if (this.payloads == null) return fallbacks;
1304
1305 for (Element el : this.payloads) {
1306 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1307 final String fallbackFor = el.getAttribute("for");
1308 if (fallbackFor == null) continue;
1309 for (String includeOne : includeFor) {
1310 if (fallbackFor.equals(includeOne)) {
1311 fallbacks.add(el);
1312 break;
1313 }
1314 }
1315 }
1316 }
1317
1318 return fallbacks;
1319 }
1320
1321 public Element getHtml() {
1322 return getHtml(false);
1323 }
1324
1325 public Element getHtml(boolean root) {
1326 if (this.payloads == null) return null;
1327
1328 for (Element el : this.payloads) {
1329 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1330 return root ? el : el.getChildren().get(0);
1331 }
1332 }
1333
1334 return null;
1335 }
1336
1337 public List<Element> getCommands() {
1338 if (this.payloads == null) return null;
1339
1340 for (Element el : this.payloads) {
1341 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1342 return el.getChildren();
1343 }
1344 }
1345
1346 return null;
1347 }
1348
1349 public List<Element> getLinkDescriptions() {
1350 final ArrayList<Element> result = new ArrayList<>();
1351 if (this.payloads == null) return result;
1352
1353 for (Element el : this.payloads) {
1354 if (el.getName().equals("Description") && el.getNamespace().equals("http://www.w3.org/1999/02/22-rdf-syntax-ns#")) {
1355 result.add(el);
1356 }
1357 }
1358
1359 return result;
1360 }
1361
1362 public synchronized void clearLinkDescriptions() {
1363 this.payloads.removeAll(getLinkDescriptions());
1364 }
1365
1366 public String getMimeType() {
1367 String extension;
1368 if (relativeFilePath != null) {
1369 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1370 } else {
1371 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1372 if (url == null) {
1373 return null;
1374 }
1375 extension = MimeUtils.extractRelevantExtension(url);
1376 }
1377 return MimeUtils.guessMimeTypeFromExtension(extension);
1378 }
1379
1380 public synchronized boolean treatAsDownloadable() {
1381 if (treatAsDownloadable == null) {
1382 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb(), encryption != ENCRYPTION_NONE);
1383 }
1384 return treatAsDownloadable;
1385 }
1386
1387 public synchronized boolean hasCustomEmoji() {
1388 if (getHtml() != null) {
1389 SpannableStringBuilder spannable = getSpannableBody(null, null);
1390 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1391 return imageSpans.length > 0;
1392 }
1393
1394 return false;
1395 }
1396
1397 public synchronized boolean bodyIsOnlyEmojis() {
1398 if (isEmojisOnly == null) {
1399 isEmojisOnly = Emoticons.isOnlyEmoji(getBody());
1400 if (isEmojisOnly) return true;
1401
1402 if (getHtml() != null) {
1403 SpannableStringBuilder spannable = getSpannableBody(null, null);
1404 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1405 for (ImageSpan span : imageSpans) {
1406 final int start = spannable.getSpanStart(span);
1407 final int end = spannable.getSpanEnd(span);
1408 spannable.delete(start, end);
1409 }
1410 final String after = spannable.toString().replaceAll("\\s", "");
1411 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1412 }
1413 }
1414 return isEmojisOnly;
1415 }
1416
1417 public synchronized boolean isGeoUri() {
1418 if (isGeoUri == null) {
1419 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1420 }
1421 return isGeoUri;
1422 }
1423
1424 protected List<Element> getSims() {
1425 return payloads.stream().filter(el ->
1426 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1427 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1428 ).collect(Collectors.toList());
1429 }
1430
1431 public synchronized void resetFileParams() {
1432 this.oob = false;
1433 this.fileParams = null;
1434 this.transferable = null;
1435 this.payloads.removeAll(getSims());
1436 clearFallbacks(Namespace.OOB);
1437 setType(isPrivateMessage() ? TYPE_PRIVATE : TYPE_TEXT);
1438 }
1439
1440 public synchronized void setFileParams(FileParams fileParams) {
1441 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1442 fileParams.sims = this.fileParams.sims;
1443 }
1444 this.fileParams = fileParams;
1445 if (fileParams != null && getSims().isEmpty()) {
1446 addPayload(fileParams.toSims());
1447 }
1448 }
1449
1450 public synchronized FileParams getFileParams() {
1451 if (fileParams == null) {
1452 List<Element> sims = getSims();
1453 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1454 if (this.transferable != null) {
1455 fileParams.size = this.transferable.getFileSize();
1456 }
1457 }
1458
1459 return fileParams;
1460 }
1461
1462 private static int parseInt(String value) {
1463 try {
1464 return Integer.parseInt(value);
1465 } catch (NumberFormatException e) {
1466 return 0;
1467 }
1468 }
1469
1470 public void untie() {
1471 this.mNextMessage = null;
1472 this.mPreviousMessage = null;
1473 }
1474
1475 public boolean isPrivateMessage() {
1476 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1477 }
1478
1479 public boolean isFileOrImage() {
1480 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1481 }
1482
1483
1484 public boolean isTypeText() {
1485 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1486 }
1487
1488 public boolean hasFileOnRemoteHost() {
1489 return isFileOrImage() && getFileParams().url != null;
1490 }
1491
1492 public boolean needsUploading() {
1493 return isFileOrImage() && getFileParams().url == null;
1494 }
1495
1496 public static class FileParams {
1497 public String url;
1498 public Long size = null;
1499 public int width = 0;
1500 public int height = 0;
1501 public int runtime = 0;
1502 public Element sims = null;
1503
1504 public FileParams() { }
1505
1506 public FileParams(Element el) {
1507 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1508 this.url = el.findChildContent("url", Namespace.OOB);
1509 }
1510 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1511 sims = el;
1512 final String refUri = el.getAttribute("uri");
1513 if (refUri != null) url = refUri;
1514 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1515 if (mediaSharing != null) {
1516 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1517 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1518 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1519 if (file != null) {
1520 try {
1521 String sizeS = file.findChildContent("size", file.getNamespace());
1522 if (sizeS != null) size = new Long(sizeS);
1523 String widthS = file.findChildContent("width", "https://schema.org/");
1524 if (widthS != null) width = parseInt(widthS);
1525 String heightS = file.findChildContent("height", "https://schema.org/");
1526 if (heightS != null) height = parseInt(heightS);
1527 String durationS = file.findChildContent("duration", "https://schema.org/");
1528 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1529 } catch (final NumberFormatException e) {
1530 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1531 }
1532 }
1533
1534 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1535 if (sources != null) {
1536 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1537 if (ref != null) url = ref.getAttribute("uri");
1538 }
1539 }
1540 }
1541 }
1542
1543 public FileParams(String ser) {
1544 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1545 switch (parts.length) {
1546 case 1:
1547 try {
1548 this.size = Long.parseLong(parts[0]);
1549 } catch (final NumberFormatException e) {
1550 this.url = URL.tryParse(parts[0]);
1551 }
1552 break;
1553 case 5:
1554 this.runtime = parseInt(parts[4]);
1555 case 4:
1556 this.width = parseInt(parts[2]);
1557 this.height = parseInt(parts[3]);
1558 case 2:
1559 this.url = URL.tryParse(parts[0]);
1560 this.size = Longs.tryParse(parts[1]);
1561 break;
1562 case 3:
1563 this.size = Longs.tryParse(parts[0]);
1564 this.width = parseInt(parts[1]);
1565 this.height = parseInt(parts[2]);
1566 break;
1567 }
1568 }
1569
1570 public boolean isEmpty() {
1571 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1572 }
1573
1574 public long getSize() {
1575 return size == null ? 0 : size;
1576 }
1577
1578 public String getName() {
1579 Element file = getFileElement();
1580 if (file == null) return null;
1581
1582 return file.findChildContent("name", file.getNamespace());
1583 }
1584
1585 public void setName(final String name) {
1586 if (sims == null) toSims();
1587 Element file = getFileElement();
1588
1589 for (Element child : file.getChildren()) {
1590 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1591 file.removeChild(child);
1592 }
1593 }
1594
1595 if (name != null) {
1596 file.addChild("name", file.getNamespace()).setContent(name);
1597 }
1598 }
1599
1600 public String getMediaType() {
1601 Element file = getFileElement();
1602 if (file == null) return null;
1603
1604 return file.findChildContent("media-type", file.getNamespace());
1605 }
1606
1607 public void setMediaType(final String mime) {
1608 if (sims == null) toSims();
1609 Element file = getFileElement();
1610
1611 for (Element child : file.getChildren()) {
1612 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1613 file.removeChild(child);
1614 }
1615 }
1616
1617 if (mime != null) {
1618 file.addChild("media-type", file.getNamespace()).setContent(mime);
1619 }
1620 }
1621
1622 public Element toSims() {
1623 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1624 sims.setAttribute("type", "data");
1625 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1626 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1627
1628 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1629 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1630 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1631 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1632
1633 file.removeChild(file.findChild("size", file.getNamespace()));
1634 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1635
1636 file.removeChild(file.findChild("width", "https://schema.org/"));
1637 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1638
1639 file.removeChild(file.findChild("height", "https://schema.org/"));
1640 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1641
1642 file.removeChild(file.findChild("duration", "https://schema.org/"));
1643 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1644
1645 if (url != null) {
1646 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1647 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1648
1649 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1650 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1651 source.setAttribute("type", "data");
1652 source.setAttribute("uri", url);
1653 }
1654
1655 return sims;
1656 }
1657
1658 protected Element getFileElement() {
1659 Element file = null;
1660 if (sims == null) return file;
1661
1662 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1663 if (mediaSharing == null) return file;
1664 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1665 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1666 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1667 return file;
1668 }
1669
1670 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1671 if (sims == null) toSims();
1672 Element file = getFileElement();
1673
1674 for (Element child : file.getChildren()) {
1675 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1676 file.removeChild(child);
1677 }
1678 }
1679
1680 for (Cid cid : cids) {
1681 file.addChild("hash", "urn:xmpp:hashes:2")
1682 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1683 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1684 }
1685 }
1686
1687 public List<Cid> getCids() {
1688 List<Cid> cids = new ArrayList<>();
1689 Element file = getFileElement();
1690 if (file == null) return cids;
1691
1692 for (Element child : file.getChildren()) {
1693 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1694 try {
1695 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1696 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1697 }
1698 }
1699
1700 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1701
1702 return cids;
1703 }
1704
1705 public void addThumbnail(int width, int height, String mimeType, String uri) {
1706 for (Element thumb : getThumbnails()) {
1707 if (uri.equals(thumb.getAttribute("uri"))) return;
1708 }
1709
1710 if (sims == null) toSims();
1711 Element file = getFileElement();
1712 file.addChild(
1713 new Element("thumbnail", "urn:xmpp:thumbs:1")
1714 .setAttribute("width", Integer.toString(width))
1715 .setAttribute("height", Integer.toString(height))
1716 .setAttribute("type", mimeType)
1717 .setAttribute("uri", uri)
1718 );
1719 }
1720
1721 public List<Element> getThumbnails() {
1722 List<Element> thumbs = new ArrayList<>();
1723 Element file = getFileElement();
1724 if (file == null) return thumbs;
1725
1726 for (Element child : file.getChildren()) {
1727 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1728 thumbs.add(child);
1729 }
1730 }
1731
1732 return thumbs;
1733 }
1734
1735 public String toString() {
1736 final StringBuilder builder = new StringBuilder();
1737 if (url != null) builder.append(url);
1738 if (size != null) builder.append('|').append(size.toString());
1739 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1740 if (height > 0 || runtime > 0) builder.append('|').append(height);
1741 if (runtime > 0) builder.append('|').append(runtime);
1742 return builder.toString();
1743 }
1744
1745 public boolean equals(Object o) {
1746 if (!(o instanceof FileParams)) return false;
1747 if (url == null) return false;
1748
1749 return url.equals(((FileParams) o).url);
1750 }
1751
1752 public int hashCode() {
1753 return url == null ? super.hashCode() : url.hashCode();
1754 }
1755 }
1756
1757 public void setFingerprint(String fingerprint) {
1758 this.axolotlFingerprint = fingerprint;
1759 }
1760
1761 public String getFingerprint() {
1762 return axolotlFingerprint;
1763 }
1764
1765 public boolean isTrusted() {
1766 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1767 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1768 return s != null && s.isTrusted();
1769 }
1770
1771 private int getPreviousEncryption() {
1772 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1773 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1774 continue;
1775 }
1776 return iterator.getEncryption();
1777 }
1778 return ENCRYPTION_NONE;
1779 }
1780
1781 private int getNextEncryption() {
1782 if (this.conversation instanceof Conversation) {
1783 Conversation conversation = (Conversation) this.conversation;
1784 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1785 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1786 continue;
1787 }
1788 return iterator.getEncryption();
1789 }
1790 return conversation.getNextEncryption();
1791 } else {
1792 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1793 }
1794 }
1795
1796 public boolean isValidInSession() {
1797 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1798 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1799
1800 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1801 || futureEncryption == ENCRYPTION_NONE
1802 || pastEncryption != futureEncryption;
1803
1804 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1805 }
1806
1807 private static int getCleanedEncryption(int encryption) {
1808 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1809 return ENCRYPTION_PGP;
1810 }
1811 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1812 return ENCRYPTION_AXOLOTL;
1813 }
1814 return encryption;
1815 }
1816
1817 public static boolean configurePrivateMessage(final Message message) {
1818 return configurePrivateMessage(message, false);
1819 }
1820
1821 public static boolean configurePrivateFileMessage(final Message message) {
1822 return configurePrivateMessage(message, true);
1823 }
1824
1825 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1826 final Conversation conversation;
1827 if (message.conversation instanceof Conversation) {
1828 conversation = (Conversation) message.conversation;
1829 } else {
1830 return false;
1831 }
1832 if (conversation.getMode() == Conversation.MODE_MULTI) {
1833 final Jid nextCounterpart = conversation.getNextCounterpart();
1834 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1835 }
1836 return false;
1837 }
1838
1839 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1840 final Conversation conversation;
1841 if (message.conversation instanceof Conversation) {
1842 conversation = (Conversation) message.conversation;
1843 } else {
1844 return false;
1845 }
1846 return configurePrivateMessage(conversation, message, counterpart, false);
1847 }
1848
1849 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1850 if (counterpart == null) {
1851 return false;
1852 }
1853 message.setCounterpart(counterpart);
1854 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1855 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1856 return true;
1857 }
1858
1859 public static class PlainTextSpan {}
1860}