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