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