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