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