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 String getMimeType() {
1327 String extension;
1328 if (relativeFilePath != null) {
1329 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1330 } else {
1331 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1332 if (url == null) {
1333 return null;
1334 }
1335 extension = MimeUtils.extractRelevantExtension(url);
1336 }
1337 return MimeUtils.guessMimeTypeFromExtension(extension);
1338 }
1339
1340 public synchronized boolean treatAsDownloadable() {
1341 if (treatAsDownloadable == null) {
1342 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1343 }
1344 return treatAsDownloadable;
1345 }
1346
1347 public synchronized boolean hasCustomEmoji() {
1348 if (getHtml() != null) {
1349 SpannableStringBuilder spannable = getSpannableBody(null, null);
1350 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1351 return imageSpans.length > 0;
1352 }
1353
1354 return false;
1355 }
1356
1357 public synchronized boolean bodyIsOnlyEmojis() {
1358 if (isEmojisOnly == null) {
1359 isEmojisOnly = Emoticons.isOnlyEmoji(getBody());
1360 if (isEmojisOnly) return true;
1361
1362 if (getHtml() != null) {
1363 SpannableStringBuilder spannable = getSpannableBody(null, null);
1364 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1365 for (ImageSpan span : imageSpans) {
1366 final int start = spannable.getSpanStart(span);
1367 final int end = spannable.getSpanEnd(span);
1368 spannable.delete(start, end);
1369 }
1370 final String after = spannable.toString().replaceAll("\\s", "");
1371 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1372 }
1373 }
1374 return isEmojisOnly;
1375 }
1376
1377 public synchronized boolean isGeoUri() {
1378 if (isGeoUri == null) {
1379 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1380 }
1381 return isGeoUri;
1382 }
1383
1384 protected List<Element> getSims() {
1385 return payloads.stream().filter(el ->
1386 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1387 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1388 ).collect(Collectors.toList());
1389 }
1390
1391 public synchronized void resetFileParams() {
1392 this.oob = false;
1393 this.fileParams = null;
1394 this.transferable = null;
1395 this.payloads.removeAll(getSims());
1396 clearFallbacks(Namespace.OOB);
1397 setType(isPrivateMessage() ? TYPE_PRIVATE : TYPE_TEXT);
1398 }
1399
1400 public synchronized void setFileParams(FileParams fileParams) {
1401 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1402 fileParams.sims = this.fileParams.sims;
1403 }
1404 this.fileParams = fileParams;
1405 if (fileParams != null && getSims().isEmpty()) {
1406 addPayload(fileParams.toSims());
1407 }
1408 }
1409
1410 public synchronized FileParams getFileParams() {
1411 if (fileParams == null) {
1412 List<Element> sims = getSims();
1413 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1414 if (this.transferable != null) {
1415 fileParams.size = this.transferable.getFileSize();
1416 }
1417 }
1418
1419 return fileParams;
1420 }
1421
1422 private static int parseInt(String value) {
1423 try {
1424 return Integer.parseInt(value);
1425 } catch (NumberFormatException e) {
1426 return 0;
1427 }
1428 }
1429
1430 public void untie() {
1431 this.mNextMessage = null;
1432 this.mPreviousMessage = null;
1433 }
1434
1435 public boolean isPrivateMessage() {
1436 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1437 }
1438
1439 public boolean isFileOrImage() {
1440 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1441 }
1442
1443
1444 public boolean isTypeText() {
1445 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1446 }
1447
1448 public boolean hasFileOnRemoteHost() {
1449 return isFileOrImage() && getFileParams().url != null;
1450 }
1451
1452 public boolean needsUploading() {
1453 return isFileOrImage() && getFileParams().url == null;
1454 }
1455
1456 public static class FileParams {
1457 public String url;
1458 public Long size = null;
1459 public int width = 0;
1460 public int height = 0;
1461 public int runtime = 0;
1462 public Element sims = null;
1463
1464 public FileParams() { }
1465
1466 public FileParams(Element el) {
1467 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1468 this.url = el.findChildContent("url", Namespace.OOB);
1469 }
1470 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1471 sims = el;
1472 final String refUri = el.getAttribute("uri");
1473 if (refUri != null) url = refUri;
1474 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1475 if (mediaSharing != null) {
1476 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1477 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1478 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1479 if (file != null) {
1480 try {
1481 String sizeS = file.findChildContent("size", file.getNamespace());
1482 if (sizeS != null) size = new Long(sizeS);
1483 String widthS = file.findChildContent("width", "https://schema.org/");
1484 if (widthS != null) width = parseInt(widthS);
1485 String heightS = file.findChildContent("height", "https://schema.org/");
1486 if (heightS != null) height = parseInt(heightS);
1487 String durationS = file.findChildContent("duration", "https://schema.org/");
1488 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1489 } catch (final NumberFormatException e) {
1490 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1491 }
1492 }
1493
1494 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1495 if (sources != null) {
1496 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1497 if (ref != null) url = ref.getAttribute("uri");
1498 }
1499 }
1500 }
1501 }
1502
1503 public FileParams(String ser) {
1504 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1505 switch (parts.length) {
1506 case 1:
1507 try {
1508 this.size = Long.parseLong(parts[0]);
1509 } catch (final NumberFormatException e) {
1510 this.url = URL.tryParse(parts[0]);
1511 }
1512 break;
1513 case 5:
1514 this.runtime = parseInt(parts[4]);
1515 case 4:
1516 this.width = parseInt(parts[2]);
1517 this.height = parseInt(parts[3]);
1518 case 2:
1519 this.url = URL.tryParse(parts[0]);
1520 this.size = Longs.tryParse(parts[1]);
1521 break;
1522 case 3:
1523 this.size = Longs.tryParse(parts[0]);
1524 this.width = parseInt(parts[1]);
1525 this.height = parseInt(parts[2]);
1526 break;
1527 }
1528 }
1529
1530 public boolean isEmpty() {
1531 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1532 }
1533
1534 public long getSize() {
1535 return size == null ? 0 : size;
1536 }
1537
1538 public String getName() {
1539 Element file = getFileElement();
1540 if (file == null) return null;
1541
1542 return file.findChildContent("name", file.getNamespace());
1543 }
1544
1545 public void setName(final String name) {
1546 if (sims == null) toSims();
1547 Element file = getFileElement();
1548
1549 for (Element child : file.getChildren()) {
1550 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1551 file.removeChild(child);
1552 }
1553 }
1554
1555 if (name != null) {
1556 file.addChild("name", file.getNamespace()).setContent(name);
1557 }
1558 }
1559
1560 public String getMediaType() {
1561 Element file = getFileElement();
1562 if (file == null) return null;
1563
1564 return file.findChildContent("media-type", file.getNamespace());
1565 }
1566
1567 public void setMediaType(final String mime) {
1568 if (sims == null) toSims();
1569 Element file = getFileElement();
1570
1571 for (Element child : file.getChildren()) {
1572 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1573 file.removeChild(child);
1574 }
1575 }
1576
1577 if (mime != null) {
1578 file.addChild("media-type", file.getNamespace()).setContent(mime);
1579 }
1580 }
1581
1582 public Element toSims() {
1583 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1584 sims.setAttribute("type", "data");
1585 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1586 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1587
1588 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1589 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1590 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1591 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1592
1593 file.removeChild(file.findChild("size", file.getNamespace()));
1594 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1595
1596 file.removeChild(file.findChild("width", "https://schema.org/"));
1597 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1598
1599 file.removeChild(file.findChild("height", "https://schema.org/"));
1600 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1601
1602 file.removeChild(file.findChild("duration", "https://schema.org/"));
1603 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1604
1605 if (url != null) {
1606 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1607 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1608
1609 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1610 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1611 source.setAttribute("type", "data");
1612 source.setAttribute("uri", url);
1613 }
1614
1615 return sims;
1616 }
1617
1618 protected Element getFileElement() {
1619 Element file = null;
1620 if (sims == null) return file;
1621
1622 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1623 if (mediaSharing == null) return file;
1624 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1625 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1626 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1627 return file;
1628 }
1629
1630 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1631 if (sims == null) toSims();
1632 Element file = getFileElement();
1633
1634 for (Element child : file.getChildren()) {
1635 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1636 file.removeChild(child);
1637 }
1638 }
1639
1640 for (Cid cid : cids) {
1641 file.addChild("hash", "urn:xmpp:hashes:2")
1642 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1643 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1644 }
1645 }
1646
1647 public List<Cid> getCids() {
1648 List<Cid> cids = new ArrayList<>();
1649 Element file = getFileElement();
1650 if (file == null) return cids;
1651
1652 for (Element child : file.getChildren()) {
1653 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1654 try {
1655 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1656 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1657 }
1658 }
1659
1660 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1661
1662 return cids;
1663 }
1664
1665 public void addThumbnail(int width, int height, String mimeType, String uri) {
1666 for (Element thumb : getThumbnails()) {
1667 if (uri.equals(thumb.getAttribute("uri"))) return;
1668 }
1669
1670 if (sims == null) toSims();
1671 Element file = getFileElement();
1672 file.addChild(
1673 new Element("thumbnail", "urn:xmpp:thumbs:1")
1674 .setAttribute("width", Integer.toString(width))
1675 .setAttribute("height", Integer.toString(height))
1676 .setAttribute("type", mimeType)
1677 .setAttribute("uri", uri)
1678 );
1679 }
1680
1681 public List<Element> getThumbnails() {
1682 List<Element> thumbs = new ArrayList<>();
1683 Element file = getFileElement();
1684 if (file == null) return thumbs;
1685
1686 for (Element child : file.getChildren()) {
1687 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1688 thumbs.add(child);
1689 }
1690 }
1691
1692 return thumbs;
1693 }
1694
1695 public String toString() {
1696 final StringBuilder builder = new StringBuilder();
1697 if (url != null) builder.append(url);
1698 if (size != null) builder.append('|').append(size.toString());
1699 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1700 if (height > 0 || runtime > 0) builder.append('|').append(height);
1701 if (runtime > 0) builder.append('|').append(runtime);
1702 return builder.toString();
1703 }
1704
1705 public boolean equals(Object o) {
1706 if (!(o instanceof FileParams)) return false;
1707 if (url == null) return false;
1708
1709 return url.equals(((FileParams) o).url);
1710 }
1711
1712 public int hashCode() {
1713 return url == null ? super.hashCode() : url.hashCode();
1714 }
1715 }
1716
1717 public void setFingerprint(String fingerprint) {
1718 this.axolotlFingerprint = fingerprint;
1719 }
1720
1721 public String getFingerprint() {
1722 return axolotlFingerprint;
1723 }
1724
1725 public boolean isTrusted() {
1726 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1727 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1728 return s != null && s.isTrusted();
1729 }
1730
1731 private int getPreviousEncryption() {
1732 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1733 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1734 continue;
1735 }
1736 return iterator.getEncryption();
1737 }
1738 return ENCRYPTION_NONE;
1739 }
1740
1741 private int getNextEncryption() {
1742 if (this.conversation instanceof Conversation) {
1743 Conversation conversation = (Conversation) this.conversation;
1744 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1745 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1746 continue;
1747 }
1748 return iterator.getEncryption();
1749 }
1750 return conversation.getNextEncryption();
1751 } else {
1752 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1753 }
1754 }
1755
1756 public boolean isValidInSession() {
1757 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1758 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1759
1760 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1761 || futureEncryption == ENCRYPTION_NONE
1762 || pastEncryption != futureEncryption;
1763
1764 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1765 }
1766
1767 private static int getCleanedEncryption(int encryption) {
1768 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1769 return ENCRYPTION_PGP;
1770 }
1771 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1772 return ENCRYPTION_AXOLOTL;
1773 }
1774 return encryption;
1775 }
1776
1777 public static boolean configurePrivateMessage(final Message message) {
1778 return configurePrivateMessage(message, false);
1779 }
1780
1781 public static boolean configurePrivateFileMessage(final Message message) {
1782 return configurePrivateMessage(message, true);
1783 }
1784
1785 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1786 final Conversation conversation;
1787 if (message.conversation instanceof Conversation) {
1788 conversation = (Conversation) message.conversation;
1789 } else {
1790 return false;
1791 }
1792 if (conversation.getMode() == Conversation.MODE_MULTI) {
1793 final Jid nextCounterpart = conversation.getNextCounterpart();
1794 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1795 }
1796 return false;
1797 }
1798
1799 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1800 final Conversation conversation;
1801 if (message.conversation instanceof Conversation) {
1802 conversation = (Conversation) message.conversation;
1803 } else {
1804 return false;
1805 }
1806 return configurePrivateMessage(conversation, message, counterpart, false);
1807 }
1808
1809 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1810 if (counterpart == null) {
1811 return false;
1812 }
1813 message.setCounterpart(counterpart);
1814 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1815 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1816 return true;
1817 }
1818
1819 public static class PlainTextSpan {}
1820}