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