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