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