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