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