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