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 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1075 MessageUtils.filterLtrRtl(html.toString()).trim(),
1076 Html.FROM_HTML_MODE_COMPACT,
1077 (source) -> {
1078 try {
1079 if (thumbnailer == null || source == null) {
1080 return fallbackImg;
1081 }
1082 Cid cid = BobTransfer.cid(new URI(source));
1083 if (cid == null) {
1084 return fallbackImg;
1085 }
1086 Drawable thumbnail = thumbnailer.getThumbnail(cid);
1087 if (thumbnail == null) {
1088 return fallbackImg;
1089 }
1090 return thumbnail;
1091 } catch (final URISyntaxException e) {
1092 return fallbackImg;
1093 }
1094 },
1095 (opening, tag, output, xmlReader) -> {}
1096 ));
1097
1098 // Make images clickable and long-clickable with BetterLinkMovementMethod
1099 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1100 for (ImageSpan span : imageSpans) {
1101 final int start = spannable.getSpanStart(span);
1102 final int end = spannable.getSpanEnd(span);
1103
1104 ClickableSpan click_span = new ClickableSpan() {
1105 @Override
1106 public void onClick(View widget) { }
1107 };
1108
1109 spannable.removeSpan(span);
1110 spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1111 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1112 }
1113
1114 // https://stackoverflow.com/a/10187511/8611
1115 int i = spannable.length();
1116 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1117 spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1118 }
1119
1120 if (getInReplyTo() != null && getModerated() == null) {
1121 // Don't show quote if it's the message right before us
1122 if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
1123
1124 final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1125 if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1126 quote.insert(0, "🖼️");
1127 final var cid = getInReplyTo().getFileParams().getCids().size() < 1 ? null : getInReplyTo().getFileParams().getCids().get(0);
1128 Drawable thumbnail = thumbnailer == null || cid == null ? null : thumbnailer.getThumbnail(cid);
1129 if (thumbnail == null) thumbnail = fallbackImg;
1130 if (thumbnail != null) {
1131 quote.setSpan(new InlineImageSpan(thumbnail, cid == null ? null : cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1132 }
1133 }
1134 quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1135 spannableBody.insert(0, "\n");
1136 spannableBody.insert(0, quote);
1137 }
1138
1139 return spannableBody;
1140 }
1141
1142 public SpannableStringBuilder getMergedBody() {
1143 return getMergedBody(null, null);
1144 }
1145
1146 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1147 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1148 Message current = this;
1149 while (current.mergeable(current.next())) {
1150 current = current.next();
1151 if (current == null || current.getModerated() != null) {
1152 break;
1153 }
1154 body.append("\n\n");
1155 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1156 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1157 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1158 }
1159 return body;
1160 }
1161
1162 public boolean hasMeCommand() {
1163 return this.body.trim().startsWith(ME_COMMAND);
1164 }
1165
1166 public int getMergedStatus() {
1167 int status = this.status;
1168 Message current = this;
1169 while (current.mergeable(current.next())) {
1170 current = current.next();
1171 if (current == null) {
1172 break;
1173 }
1174 status = current.status;
1175 }
1176 return status;
1177 }
1178
1179 public long getMergedTimeSent() {
1180 long time = this.timeSent;
1181 Message current = this;
1182 while (current.mergeable(current.next())) {
1183 current = current.next();
1184 if (current == null) {
1185 break;
1186 }
1187 time = current.timeSent;
1188 }
1189 return time;
1190 }
1191
1192 public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1193 Message prev = this.prev();
1194 if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1195 if (getOccupantId() != null && xmppConnectionService != null) {
1196 final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1197 if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1198 }
1199 return prev != null && prev.mergeable(this);
1200 }
1201
1202 public boolean trusted() {
1203 Contact contact = this.getContact();
1204 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1205 }
1206
1207 public boolean fixCounterpart() {
1208 final Presences presences = conversation.getContact().getPresences();
1209 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1210 return true;
1211 } else if (presences.size() >= 1) {
1212 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1213 return true;
1214 } else {
1215 counterpart = null;
1216 return false;
1217 }
1218 }
1219
1220 public void setUuid(String uuid) {
1221 this.uuid = uuid;
1222 }
1223
1224 public String getEditedId() {
1225 if (edits.size() > 0) {
1226 return edits.get(edits.size() - 1).getEditedId();
1227 } else {
1228 throw new IllegalStateException("Attempting to store unedited message");
1229 }
1230 }
1231
1232 public String getEditedIdWireFormat() {
1233 if (edits.size() > 0) {
1234 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1235 } else {
1236 throw new IllegalStateException("Attempting to store unedited message");
1237 }
1238 }
1239
1240 public List<URI> getLinks() {
1241 SpannableStringBuilder text = new SpannableStringBuilder(
1242 getBody().replaceAll("^>.*", "") // Remove quotes
1243 );
1244 return MyLinkify.extractLinks(text).stream().map((url) -> {
1245 try {
1246 return new URI(url);
1247 } catch (final URISyntaxException e) {
1248 return null;
1249 }
1250 }).filter(x -> x != null).collect(Collectors.toList());
1251 }
1252
1253 public URI getOob() {
1254 final String url = getFileParams().url;
1255 try {
1256 return url == null ? null : new URI(url);
1257 } catch (final URISyntaxException e) {
1258 return null;
1259 }
1260 }
1261
1262 public void clearPayloads() {
1263 this.payloads.clear();
1264 }
1265
1266 public void addPayload(Element el) {
1267 if (el == null) return;
1268
1269 this.payloads.add(el);
1270 }
1271
1272 public List<Element> getPayloads() {
1273 return new ArrayList<>(this.payloads);
1274 }
1275
1276 public List<Element> getFallbacks(String... includeFor) {
1277 List<Element> fallbacks = new ArrayList<>();
1278
1279 if (this.payloads == null) return fallbacks;
1280
1281 for (Element el : this.payloads) {
1282 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1283 final String fallbackFor = el.getAttribute("for");
1284 if (fallbackFor == null) continue;
1285 for (String includeOne : includeFor) {
1286 if (fallbackFor.equals(includeOne)) {
1287 fallbacks.add(el);
1288 break;
1289 }
1290 }
1291 }
1292 }
1293
1294 return fallbacks;
1295 }
1296
1297 public Element getHtml() {
1298 return getHtml(false);
1299 }
1300
1301 public Element getHtml(boolean root) {
1302 if (this.payloads == null) return null;
1303
1304 for (Element el : this.payloads) {
1305 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1306 return root ? el : el.getChildren().get(0);
1307 }
1308 }
1309
1310 return null;
1311 }
1312
1313 public List<Element> getCommands() {
1314 if (this.payloads == null) return null;
1315
1316 for (Element el : this.payloads) {
1317 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1318 return el.getChildren();
1319 }
1320 }
1321
1322 return null;
1323 }
1324
1325 public String getMimeType() {
1326 String extension;
1327 if (relativeFilePath != null) {
1328 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1329 } else {
1330 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1331 if (url == null) {
1332 return null;
1333 }
1334 extension = MimeUtils.extractRelevantExtension(url);
1335 }
1336 return MimeUtils.guessMimeTypeFromExtension(extension);
1337 }
1338
1339 public synchronized boolean treatAsDownloadable() {
1340 if (treatAsDownloadable == null) {
1341 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1342 }
1343 return treatAsDownloadable;
1344 }
1345
1346 public synchronized boolean hasCustomEmoji() {
1347 if (getHtml() != null) {
1348 SpannableStringBuilder spannable = getSpannableBody(null, null);
1349 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1350 return imageSpans.length > 0;
1351 }
1352
1353 return false;
1354 }
1355
1356 public synchronized boolean bodyIsOnlyEmojis() {
1357 if (isEmojisOnly == null) {
1358 isEmojisOnly = Emoticons.isOnlyEmoji(getBody());
1359 if (isEmojisOnly) return true;
1360
1361 if (getHtml() != null) {
1362 SpannableStringBuilder spannable = getSpannableBody(null, null);
1363 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1364 for (ImageSpan span : imageSpans) {
1365 final int start = spannable.getSpanStart(span);
1366 final int end = spannable.getSpanEnd(span);
1367 spannable.delete(start, end);
1368 }
1369 final String after = spannable.toString().replaceAll("\\s", "");
1370 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1371 }
1372 }
1373 return isEmojisOnly;
1374 }
1375
1376 public synchronized boolean isGeoUri() {
1377 if (isGeoUri == null) {
1378 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1379 }
1380 return isGeoUri;
1381 }
1382
1383 protected List<Element> getSims() {
1384 return payloads.stream().filter(el ->
1385 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1386 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1387 ).collect(Collectors.toList());
1388 }
1389
1390 public synchronized void resetFileParams() {
1391 this.fileParams = null;
1392 }
1393
1394 public synchronized void setFileParams(FileParams fileParams) {
1395 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1396 fileParams.sims = this.fileParams.sims;
1397 }
1398 this.fileParams = fileParams;
1399 if (fileParams != null && getSims().isEmpty()) {
1400 addPayload(fileParams.toSims());
1401 }
1402 }
1403
1404 public synchronized FileParams getFileParams() {
1405 if (fileParams == null) {
1406 List<Element> sims = getSims();
1407 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1408 if (this.transferable != null) {
1409 fileParams.size = this.transferable.getFileSize();
1410 }
1411 }
1412
1413 return fileParams;
1414 }
1415
1416 private static int parseInt(String value) {
1417 try {
1418 return Integer.parseInt(value);
1419 } catch (NumberFormatException e) {
1420 return 0;
1421 }
1422 }
1423
1424 public void untie() {
1425 this.mNextMessage = null;
1426 this.mPreviousMessage = null;
1427 }
1428
1429 public boolean isPrivateMessage() {
1430 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1431 }
1432
1433 public boolean isFileOrImage() {
1434 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1435 }
1436
1437
1438 public boolean isTypeText() {
1439 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1440 }
1441
1442 public boolean hasFileOnRemoteHost() {
1443 return isFileOrImage() && getFileParams().url != null;
1444 }
1445
1446 public boolean needsUploading() {
1447 return isFileOrImage() && getFileParams().url == null;
1448 }
1449
1450 public static class FileParams {
1451 public String url;
1452 public Long size = null;
1453 public int width = 0;
1454 public int height = 0;
1455 public int runtime = 0;
1456 public Element sims = null;
1457
1458 public FileParams() { }
1459
1460 public FileParams(Element el) {
1461 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1462 this.url = el.findChildContent("url", Namespace.OOB);
1463 }
1464 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1465 sims = el;
1466 final String refUri = el.getAttribute("uri");
1467 if (refUri != null) url = refUri;
1468 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1469 if (mediaSharing != null) {
1470 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1471 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1472 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1473 if (file != null) {
1474 try {
1475 String sizeS = file.findChildContent("size", file.getNamespace());
1476 if (sizeS != null) size = new Long(sizeS);
1477 String widthS = file.findChildContent("width", "https://schema.org/");
1478 if (widthS != null) width = parseInt(widthS);
1479 String heightS = file.findChildContent("height", "https://schema.org/");
1480 if (heightS != null) height = parseInt(heightS);
1481 String durationS = file.findChildContent("duration", "https://schema.org/");
1482 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1483 } catch (final NumberFormatException e) {
1484 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1485 }
1486 }
1487
1488 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1489 if (sources != null) {
1490 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1491 if (ref != null) url = ref.getAttribute("uri");
1492 }
1493 }
1494 }
1495 }
1496
1497 public FileParams(String ser) {
1498 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1499 switch (parts.length) {
1500 case 1:
1501 try {
1502 this.size = Long.parseLong(parts[0]);
1503 } catch (final NumberFormatException e) {
1504 this.url = URL.tryParse(parts[0]);
1505 }
1506 break;
1507 case 5:
1508 this.runtime = parseInt(parts[4]);
1509 case 4:
1510 this.width = parseInt(parts[2]);
1511 this.height = parseInt(parts[3]);
1512 case 2:
1513 this.url = URL.tryParse(parts[0]);
1514 this.size = Longs.tryParse(parts[1]);
1515 break;
1516 case 3:
1517 this.size = Longs.tryParse(parts[0]);
1518 this.width = parseInt(parts[1]);
1519 this.height = parseInt(parts[2]);
1520 break;
1521 }
1522 }
1523
1524 public boolean isEmpty() {
1525 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1526 }
1527
1528 public long getSize() {
1529 return size == null ? 0 : size;
1530 }
1531
1532 public String getName() {
1533 Element file = getFileElement();
1534 if (file == null) return null;
1535
1536 return file.findChildContent("name", file.getNamespace());
1537 }
1538
1539 public void setName(final String name) {
1540 if (sims == null) toSims();
1541 Element file = getFileElement();
1542
1543 for (Element child : file.getChildren()) {
1544 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1545 file.removeChild(child);
1546 }
1547 }
1548
1549 if (name != null) {
1550 file.addChild("name", file.getNamespace()).setContent(name);
1551 }
1552 }
1553
1554 public String getMediaType() {
1555 Element file = getFileElement();
1556 if (file == null) return null;
1557
1558 return file.findChildContent("media-type", file.getNamespace());
1559 }
1560
1561 public void setMediaType(final String mime) {
1562 if (sims == null) toSims();
1563 Element file = getFileElement();
1564
1565 for (Element child : file.getChildren()) {
1566 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1567 file.removeChild(child);
1568 }
1569 }
1570
1571 if (mime != null) {
1572 file.addChild("media-type", file.getNamespace()).setContent(mime);
1573 }
1574 }
1575
1576 public Element toSims() {
1577 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1578 sims.setAttribute("type", "data");
1579 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1580 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1581
1582 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1583 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1584 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1585 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1586
1587 file.removeChild(file.findChild("size", file.getNamespace()));
1588 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1589
1590 file.removeChild(file.findChild("width", "https://schema.org/"));
1591 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1592
1593 file.removeChild(file.findChild("height", "https://schema.org/"));
1594 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1595
1596 file.removeChild(file.findChild("duration", "https://schema.org/"));
1597 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1598
1599 if (url != null) {
1600 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1601 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1602
1603 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1604 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1605 source.setAttribute("type", "data");
1606 source.setAttribute("uri", url);
1607 }
1608
1609 return sims;
1610 }
1611
1612 protected Element getFileElement() {
1613 Element file = null;
1614 if (sims == null) return file;
1615
1616 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1617 if (mediaSharing == null) return file;
1618 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1619 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1620 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1621 return file;
1622 }
1623
1624 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1625 if (sims == null) toSims();
1626 Element file = getFileElement();
1627
1628 for (Element child : file.getChildren()) {
1629 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1630 file.removeChild(child);
1631 }
1632 }
1633
1634 for (Cid cid : cids) {
1635 file.addChild("hash", "urn:xmpp:hashes:2")
1636 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1637 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1638 }
1639 }
1640
1641 public List<Cid> getCids() {
1642 List<Cid> cids = new ArrayList<>();
1643 Element file = getFileElement();
1644 if (file == null) return cids;
1645
1646 for (Element child : file.getChildren()) {
1647 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1648 try {
1649 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1650 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1651 }
1652 }
1653
1654 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1655
1656 return cids;
1657 }
1658
1659 public void addThumbnail(int width, int height, String mimeType, String uri) {
1660 for (Element thumb : getThumbnails()) {
1661 if (uri.equals(thumb.getAttribute("uri"))) return;
1662 }
1663
1664 if (sims == null) toSims();
1665 Element file = getFileElement();
1666 file.addChild(
1667 new Element("thumbnail", "urn:xmpp:thumbs:1")
1668 .setAttribute("width", Integer.toString(width))
1669 .setAttribute("height", Integer.toString(height))
1670 .setAttribute("type", mimeType)
1671 .setAttribute("uri", uri)
1672 );
1673 }
1674
1675 public List<Element> getThumbnails() {
1676 List<Element> thumbs = new ArrayList<>();
1677 Element file = getFileElement();
1678 if (file == null) return thumbs;
1679
1680 for (Element child : file.getChildren()) {
1681 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1682 thumbs.add(child);
1683 }
1684 }
1685
1686 return thumbs;
1687 }
1688
1689 public String toString() {
1690 final StringBuilder builder = new StringBuilder();
1691 if (url != null) builder.append(url);
1692 if (size != null) builder.append('|').append(size.toString());
1693 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1694 if (height > 0 || runtime > 0) builder.append('|').append(height);
1695 if (runtime > 0) builder.append('|').append(runtime);
1696 return builder.toString();
1697 }
1698
1699 public boolean equals(Object o) {
1700 if (!(o instanceof FileParams)) return false;
1701 if (url == null) return false;
1702
1703 return url.equals(((FileParams) o).url);
1704 }
1705
1706 public int hashCode() {
1707 return url == null ? super.hashCode() : url.hashCode();
1708 }
1709 }
1710
1711 public void setFingerprint(String fingerprint) {
1712 this.axolotlFingerprint = fingerprint;
1713 }
1714
1715 public String getFingerprint() {
1716 return axolotlFingerprint;
1717 }
1718
1719 public boolean isTrusted() {
1720 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1721 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1722 return s != null && s.isTrusted();
1723 }
1724
1725 private int getPreviousEncryption() {
1726 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1727 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1728 continue;
1729 }
1730 return iterator.getEncryption();
1731 }
1732 return ENCRYPTION_NONE;
1733 }
1734
1735 private int getNextEncryption() {
1736 if (this.conversation instanceof Conversation) {
1737 Conversation conversation = (Conversation) this.conversation;
1738 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1739 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1740 continue;
1741 }
1742 return iterator.getEncryption();
1743 }
1744 return conversation.getNextEncryption();
1745 } else {
1746 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1747 }
1748 }
1749
1750 public boolean isValidInSession() {
1751 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1752 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1753
1754 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1755 || futureEncryption == ENCRYPTION_NONE
1756 || pastEncryption != futureEncryption;
1757
1758 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1759 }
1760
1761 private static int getCleanedEncryption(int encryption) {
1762 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1763 return ENCRYPTION_PGP;
1764 }
1765 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1766 return ENCRYPTION_AXOLOTL;
1767 }
1768 return encryption;
1769 }
1770
1771 public static boolean configurePrivateMessage(final Message message) {
1772 return configurePrivateMessage(message, false);
1773 }
1774
1775 public static boolean configurePrivateFileMessage(final Message message) {
1776 return configurePrivateMessage(message, true);
1777 }
1778
1779 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1780 final Conversation conversation;
1781 if (message.conversation instanceof Conversation) {
1782 conversation = (Conversation) message.conversation;
1783 } else {
1784 return false;
1785 }
1786 if (conversation.getMode() == Conversation.MODE_MULTI) {
1787 final Jid nextCounterpart = conversation.getNextCounterpart();
1788 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1789 }
1790 return false;
1791 }
1792
1793 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1794 final Conversation conversation;
1795 if (message.conversation instanceof Conversation) {
1796 conversation = (Conversation) message.conversation;
1797 } else {
1798 return false;
1799 }
1800 return configurePrivateMessage(conversation, message, counterpart, false);
1801 }
1802
1803 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1804 if (counterpart == null) {
1805 return false;
1806 }
1807 message.setCounterpart(counterpart);
1808 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1809 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1810 return true;
1811 }
1812
1813 public static class PlainTextSpan {}
1814}