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