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