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", 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 thisUser != null && thisUser == otherUser;
700 }
701
702 public String getErrorMessage() {
703 return errorMessage;
704 }
705
706 public boolean setErrorMessage(String message) {
707 boolean changed = (message != null && !message.equals(errorMessage))
708 || (message == null && errorMessage != null);
709 this.errorMessage = message;
710 return changed;
711 }
712
713 public long getTimeReceived() {
714 return timeReceived;
715 }
716
717 public long getTimeSent() {
718 return timeSent;
719 }
720
721 public int getEncryption() {
722 return encryption;
723 }
724
725 public void setEncryption(int encryption) {
726 this.encryption = encryption;
727 }
728
729 public int getStatus() {
730 return status;
731 }
732
733 public void setStatus(int status) {
734 this.status = status;
735 }
736
737 public String getRelativeFilePath() {
738 return this.relativeFilePath;
739 }
740
741 public void setRelativeFilePath(String path) {
742 this.relativeFilePath = path;
743 }
744
745 public String getRemoteMsgId() {
746 return this.remoteMsgId;
747 }
748
749 public void setRemoteMsgId(String id) {
750 this.remoteMsgId = id;
751 }
752
753 public String getServerMsgId() {
754 return this.serverMsgId;
755 }
756
757 public void setServerMsgId(String id) {
758 this.serverMsgId = id;
759 }
760
761 public boolean isRead() {
762 return this.read;
763 }
764
765 public boolean isDeleted() {
766 return this.deleted;
767 }
768
769 public Element getModerated() {
770 if (this.payloads == null) return null;
771
772 for (Element el : this.payloads) {
773 if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
774 return el;
775 }
776 }
777
778 return null;
779 }
780
781 public void setDeleted(boolean deleted) {
782 this.deleted = deleted;
783 }
784
785 public void markRead() {
786 this.read = true;
787 }
788
789 public void markUnread() {
790 this.read = false;
791 }
792
793 public void setTime(long time) {
794 this.timeSent = time;
795 }
796
797 public void setTimeReceived(long time) {
798 this.timeReceived = time;
799 }
800
801 public String getEncryptedBody() {
802 return this.encryptedBody;
803 }
804
805 public void setEncryptedBody(String body) {
806 this.encryptedBody = body;
807 }
808
809 public int getType() {
810 return this.type;
811 }
812
813 public void setType(int type) {
814 this.type = type;
815 }
816
817 public boolean isCarbon() {
818 return carbon;
819 }
820
821 public void setCarbon(boolean carbon) {
822 this.carbon = carbon;
823 }
824
825 public void putEdited(String edited, String serverMsgId) {
826 final Edit edit = new Edit(edited, serverMsgId);
827 if (this.edits.size() < 128 && !this.edits.contains(edit)) {
828 this.edits.add(edit);
829 }
830 }
831
832 boolean remoteMsgIdMatchInEdit(String id) {
833 for (Edit edit : this.edits) {
834 if (id.equals(edit.getEditedId())) {
835 return true;
836 }
837 }
838 return false;
839 }
840
841 public String getBodyLanguage() {
842 return this.bodyLanguage;
843 }
844
845 public void setBodyLanguage(String language) {
846 this.bodyLanguage = language;
847 }
848
849 public boolean edited() {
850 return this.edits.size() > 0;
851 }
852
853 public void setTrueCounterpart(Jid trueCounterpart) {
854 this.trueCounterpart = trueCounterpart;
855 }
856
857 public Jid getTrueCounterpart() {
858 return this.trueCounterpart;
859 }
860
861 public Transferable getTransferable() {
862 return this.transferable;
863 }
864
865 public synchronized void setTransferable(Transferable transferable) {
866 this.transferable = transferable;
867 }
868
869 public boolean addReadByMarker(final ReadByMarker readByMarker) {
870 if (readByMarker.getRealJid() != null) {
871 if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
872 return false;
873 }
874 } else if (readByMarker.getFullJid() != null) {
875 if (readByMarker.getFullJid().equals(counterpart)) {
876 return false;
877 }
878 }
879 if (this.readByMarkers.add(readByMarker)) {
880 if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
881 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
882 while (iterator.hasNext()) {
883 ReadByMarker marker = iterator.next();
884 if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
885 iterator.remove();
886 }
887 }
888 }
889 return true;
890 } else {
891 return false;
892 }
893 }
894
895 public Set<ReadByMarker> getReadByMarkers() {
896 return ImmutableSet.copyOf(this.readByMarkers);
897 }
898
899 public Set<Jid> getReadyByTrue() {
900 return ImmutableSet.copyOf(
901 Collections2.transform(
902 Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
903 ReadByMarker::getRealJid));
904 }
905
906 public void setInReplyTo(final Message m) {
907 mInReplyTo = m;
908 }
909
910 public Message getInReplyTo() {
911 return mInReplyTo;
912 }
913
914 boolean similar(Message message) {
915 if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
916 return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
917 } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
918 return true;
919 } else if (this.body == null || this.counterpart == null) {
920 return false;
921 } else {
922 String body, otherBody;
923 if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
924 body = getFileParams().url;
925 otherBody = message.body == null ? null : message.body.trim();
926 } else {
927 body = this.body;
928 otherBody = message.body;
929 }
930 final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
931 if (message.getRemoteMsgId() != null) {
932 final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
933 if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
934 return true;
935 }
936 return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
937 && matchingCounterpart
938 && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
939 } else {
940 return this.remoteMsgId == null
941 && matchingCounterpart
942 && body.equals(otherBody)
943 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
944 }
945 }
946 }
947
948 public Message next() {
949 if (this.conversation instanceof Conversation) {
950 final Conversation conversation = (Conversation) this.conversation;
951 synchronized (conversation.messages) {
952 if (this.mNextMessage == null) {
953 int index = conversation.messages.indexOf(this);
954 if (index < 0 || index >= conversation.messages.size() - 1) {
955 this.mNextMessage = null;
956 } else {
957 this.mNextMessage = conversation.messages.get(index + 1);
958 }
959 }
960 return this.mNextMessage;
961 }
962 } else {
963 throw new AssertionError("Calling next should be disabled for stubs");
964 }
965 }
966
967 public Message prev() {
968 if (this.conversation instanceof Conversation) {
969 final Conversation conversation = (Conversation) this.conversation;
970 synchronized (conversation.messages) {
971 if (this.mPreviousMessage == null) {
972 int index = conversation.messages.indexOf(this);
973 if (index <= 0 || index > conversation.messages.size()) {
974 this.mPreviousMessage = null;
975 } else {
976 this.mPreviousMessage = conversation.messages.get(index - 1);
977 }
978 }
979 }
980 return this.mPreviousMessage;
981 } else {
982 throw new AssertionError("Calling prev should be disabled for stubs");
983 }
984 }
985
986 public boolean isLastCorrectableMessage() {
987 Message next = next();
988 while (next != null) {
989 if (next.isEditable()) {
990 return false;
991 }
992 next = next.next();
993 }
994 return isEditable();
995 }
996
997 public boolean isEditable() {
998 return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
999 }
1000
1001 public boolean mergeable(final Message message) {
1002 return false; // Merrgine messages messes up reply, so disable for now
1003 }
1004
1005 private static boolean isStatusMergeable(int a, int b) {
1006 return a == b || (
1007 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
1008 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
1009 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
1010 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
1011 || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
1012 );
1013 }
1014
1015 private static boolean isEncryptionMergeable(final int a, final int b) {
1016 return a == b
1017 && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
1018 .contains(a);
1019 }
1020
1021 public void setCounterparts(List<MucOptions.User> counterparts) {
1022 this.counterparts = counterparts;
1023 }
1024
1025 public List<MucOptions.User> getCounterparts() {
1026 return this.counterparts;
1027 }
1028
1029 @Override
1030 public int getAvatarBackgroundColor() {
1031 if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
1032 return Color.TRANSPARENT;
1033 } else {
1034 return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
1035 }
1036 }
1037
1038 @Override
1039 public String getAvatarName() {
1040 return UIHelper.getMessageDisplayName(this);
1041 }
1042
1043 public boolean isOOb() {
1044 return oob || getFileParams().url != null;
1045 }
1046
1047 public static class MergeSeparator {
1048 }
1049
1050 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1051 SpannableStringBuilder spannableBody;
1052 final Element html = getHtml();
1053 if (html == null || Build.VERSION.SDK_INT < 24) {
1054 spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(getInReplyTo() != null)).trim());
1055 spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
1056 } else {
1057 boolean[] anyfallbackimg = new boolean[]{ false };
1058
1059 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1060 MessageUtils.filterLtrRtl(html.toString()).trim(),
1061 Html.FROM_HTML_MODE_COMPACT,
1062 (source) -> {
1063 try {
1064 if (thumbnailer == null || source == null) {
1065 anyfallbackimg[0] = true;
1066 return fallbackImg;
1067 }
1068 Cid cid = BobTransfer.cid(new URI(source));
1069 if (cid == null) {
1070 anyfallbackimg[0] = true;
1071 return fallbackImg;
1072 }
1073 Drawable thumbnail = thumbnailer.getThumbnail(cid);
1074 if (thumbnail == null) {
1075 anyfallbackimg[0] = true;
1076 return fallbackImg;
1077 }
1078 return thumbnail;
1079 } catch (final URISyntaxException e) {
1080 anyfallbackimg[0] = true;
1081 return fallbackImg;
1082 }
1083 },
1084 (opening, tag, output, xmlReader) -> {}
1085 ));
1086
1087 // Make images clickable and long-clickable with BetterLinkMovementMethod
1088 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1089 for (ImageSpan span : imageSpans) {
1090 final int start = spannable.getSpanStart(span);
1091 final int end = spannable.getSpanEnd(span);
1092
1093 ClickableSpan click_span = new ClickableSpan() {
1094 @Override
1095 public void onClick(View widget) { }
1096 };
1097
1098 spannable.removeSpan(span);
1099 spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1100 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1101 }
1102
1103 // https://stackoverflow.com/a/10187511/8611
1104 int i = spannable.length();
1105 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1106 if (anyfallbackimg[0]) return (SpannableStringBuilder) spannable.subSequence(0, i+1);
1107 spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1108 }
1109
1110 if (getInReplyTo() != null) {
1111 // Don't show quote if it's the message right before us
1112 if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
1113
1114 final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1115 if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1116 quote.insert(0, "🖼️");
1117 final var cid = getInReplyTo().getFileParams().getCids().get(0);
1118 Drawable thumbnail = thumbnailer == null ? null : thumbnailer.getThumbnail(cid);
1119 if (thumbnail == null) thumbnail = fallbackImg;
1120 if (thumbnail != null) {
1121 quote.setSpan(new InlineImageSpan(thumbnail, cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1122 }
1123 }
1124 quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1125 spannableBody.insert(0, "\n");
1126 spannableBody.insert(0, quote);
1127 }
1128
1129 return spannableBody;
1130 }
1131
1132 public SpannableStringBuilder getMergedBody() {
1133 return getMergedBody(null, null);
1134 }
1135
1136 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1137 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1138 Message current = this;
1139 while (current.mergeable(current.next())) {
1140 current = current.next();
1141 if (current == null || current.getModerated() != null) {
1142 break;
1143 }
1144 body.append("\n\n");
1145 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1146 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1147 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1148 }
1149 return body;
1150 }
1151
1152 public boolean hasMeCommand() {
1153 return this.body.trim().startsWith(ME_COMMAND);
1154 }
1155
1156 public int getMergedStatus() {
1157 int status = this.status;
1158 Message current = this;
1159 while (current.mergeable(current.next())) {
1160 current = current.next();
1161 if (current == null) {
1162 break;
1163 }
1164 status = current.status;
1165 }
1166 return status;
1167 }
1168
1169 public long getMergedTimeSent() {
1170 long time = this.timeSent;
1171 Message current = this;
1172 while (current.mergeable(current.next())) {
1173 current = current.next();
1174 if (current == null) {
1175 break;
1176 }
1177 time = current.timeSent;
1178 }
1179 return time;
1180 }
1181
1182 public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1183 Message prev = this.prev();
1184 if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1185 if (getOccupantId() != null && xmppConnectionService != null) {
1186 final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1187 if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1188 }
1189 return prev != null && prev.mergeable(this);
1190 }
1191
1192 public boolean trusted() {
1193 Contact contact = this.getContact();
1194 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1195 }
1196
1197 public boolean fixCounterpart() {
1198 final Presences presences = conversation.getContact().getPresences();
1199 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1200 return true;
1201 } else if (presences.size() >= 1) {
1202 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1203 return true;
1204 } else {
1205 counterpart = null;
1206 return false;
1207 }
1208 }
1209
1210 public void setUuid(String uuid) {
1211 this.uuid = uuid;
1212 }
1213
1214 public String getEditedId() {
1215 if (edits.size() > 0) {
1216 return edits.get(edits.size() - 1).getEditedId();
1217 } else {
1218 throw new IllegalStateException("Attempting to store unedited message");
1219 }
1220 }
1221
1222 public String getEditedIdWireFormat() {
1223 if (edits.size() > 0) {
1224 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1225 } else {
1226 throw new IllegalStateException("Attempting to store unedited message");
1227 }
1228 }
1229
1230 public List<URI> getLinks() {
1231 SpannableStringBuilder text = new SpannableStringBuilder(
1232 getBody().replaceAll("^>.*", "") // Remove quotes
1233 );
1234 return MyLinkify.extractLinks(text).stream().map((url) -> {
1235 try {
1236 return new URI(url);
1237 } catch (final URISyntaxException e) {
1238 return null;
1239 }
1240 }).filter(x -> x != null).collect(Collectors.toList());
1241 }
1242
1243 public URI getOob() {
1244 final String url = getFileParams().url;
1245 try {
1246 return url == null ? null : new URI(url);
1247 } catch (final URISyntaxException e) {
1248 return null;
1249 }
1250 }
1251
1252 public void clearPayloads() {
1253 this.payloads.clear();
1254 }
1255
1256 public void addPayload(Element el) {
1257 if (el == null) return;
1258
1259 this.payloads.add(el);
1260 }
1261
1262 public List<Element> getPayloads() {
1263 return new ArrayList<>(this.payloads);
1264 }
1265
1266 public List<Element> getFallbacks(String... includeFor) {
1267 List<Element> fallbacks = new ArrayList<>();
1268
1269 if (this.payloads == null) return fallbacks;
1270
1271 for (Element el : this.payloads) {
1272 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1273 final String fallbackFor = el.getAttribute("for");
1274 if (fallbackFor == null) continue;
1275 for (String includeOne : includeFor) {
1276 if (fallbackFor.equals(includeOne)) {
1277 fallbacks.add(el);
1278 break;
1279 }
1280 }
1281 }
1282 }
1283
1284 return fallbacks;
1285 }
1286
1287 public Element getHtml() {
1288 return getHtml(false);
1289 }
1290
1291 public Element getHtml(boolean root) {
1292 if (this.payloads == null) return null;
1293
1294 for (Element el : this.payloads) {
1295 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1296 return root ? el : el.getChildren().get(0);
1297 }
1298 }
1299
1300 return null;
1301 }
1302
1303 public List<Element> getCommands() {
1304 if (this.payloads == null) return null;
1305
1306 for (Element el : this.payloads) {
1307 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1308 return el.getChildren();
1309 }
1310 }
1311
1312 return null;
1313 }
1314
1315 public String getMimeType() {
1316 String extension;
1317 if (relativeFilePath != null) {
1318 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1319 } else {
1320 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1321 if (url == null) {
1322 return null;
1323 }
1324 extension = MimeUtils.extractRelevantExtension(url);
1325 }
1326 return MimeUtils.guessMimeTypeFromExtension(extension);
1327 }
1328
1329 public synchronized boolean treatAsDownloadable() {
1330 if (treatAsDownloadable == null) {
1331 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1332 }
1333 return treatAsDownloadable;
1334 }
1335
1336 public synchronized boolean hasCustomEmoji() {
1337 if (getHtml() != null) {
1338 SpannableStringBuilder spannable = getSpannableBody(null, null);
1339 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1340 return imageSpans.length > 0;
1341 }
1342
1343 return false;
1344 }
1345
1346 public synchronized boolean bodyIsOnlyEmojis() {
1347 if (isEmojisOnly == null) {
1348 isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1349 if (isEmojisOnly) return true;
1350
1351 if (getHtml() != null) {
1352 SpannableStringBuilder spannable = getSpannableBody(null, null);
1353 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1354 for (ImageSpan span : imageSpans) {
1355 final int start = spannable.getSpanStart(span);
1356 final int end = spannable.getSpanEnd(span);
1357 spannable.delete(start, end);
1358 }
1359 final String after = spannable.toString().replaceAll("\\s", "");
1360 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1361 }
1362 }
1363 return isEmojisOnly;
1364 }
1365
1366 public synchronized boolean isGeoUri() {
1367 if (isGeoUri == null) {
1368 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1369 }
1370 return isGeoUri;
1371 }
1372
1373 protected List<Element> getSims() {
1374 return payloads.stream().filter(el ->
1375 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1376 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1377 ).collect(Collectors.toList());
1378 }
1379
1380 public synchronized void resetFileParams() {
1381 this.fileParams = null;
1382 }
1383
1384 public synchronized void setFileParams(FileParams fileParams) {
1385 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1386 fileParams.sims = this.fileParams.sims;
1387 }
1388 this.fileParams = fileParams;
1389 if (fileParams != null && getSims().isEmpty()) {
1390 addPayload(fileParams.toSims());
1391 }
1392 }
1393
1394 public synchronized FileParams getFileParams() {
1395 if (fileParams == null) {
1396 List<Element> sims = getSims();
1397 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1398 if (this.transferable != null) {
1399 fileParams.size = this.transferable.getFileSize();
1400 }
1401 }
1402
1403 return fileParams;
1404 }
1405
1406 private static int parseInt(String value) {
1407 try {
1408 return Integer.parseInt(value);
1409 } catch (NumberFormatException e) {
1410 return 0;
1411 }
1412 }
1413
1414 public void untie() {
1415 this.mNextMessage = null;
1416 this.mPreviousMessage = null;
1417 }
1418
1419 public boolean isPrivateMessage() {
1420 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1421 }
1422
1423 public boolean isFileOrImage() {
1424 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1425 }
1426
1427
1428 public boolean isTypeText() {
1429 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1430 }
1431
1432 public boolean hasFileOnRemoteHost() {
1433 return isFileOrImage() && getFileParams().url != null;
1434 }
1435
1436 public boolean needsUploading() {
1437 return isFileOrImage() && getFileParams().url == null;
1438 }
1439
1440 public static class FileParams {
1441 public String url;
1442 public Long size = null;
1443 public int width = 0;
1444 public int height = 0;
1445 public int runtime = 0;
1446 public Element sims = null;
1447
1448 public FileParams() { }
1449
1450 public FileParams(Element el) {
1451 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1452 this.url = el.findChildContent("url", Namespace.OOB);
1453 }
1454 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1455 sims = el;
1456 final String refUri = el.getAttribute("uri");
1457 if (refUri != null) url = refUri;
1458 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1459 if (mediaSharing != null) {
1460 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1461 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1462 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1463 if (file != null) {
1464 try {
1465 String sizeS = file.findChildContent("size", file.getNamespace());
1466 if (sizeS != null) size = new Long(sizeS);
1467 String widthS = file.findChildContent("width", "https://schema.org/");
1468 if (widthS != null) width = parseInt(widthS);
1469 String heightS = file.findChildContent("height", "https://schema.org/");
1470 if (heightS != null) height = parseInt(heightS);
1471 String durationS = file.findChildContent("duration", "https://schema.org/");
1472 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1473 } catch (final NumberFormatException e) {
1474 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1475 }
1476 }
1477
1478 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1479 if (sources != null) {
1480 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1481 if (ref != null) url = ref.getAttribute("uri");
1482 }
1483 }
1484 }
1485 }
1486
1487 public FileParams(String ser) {
1488 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1489 switch (parts.length) {
1490 case 1:
1491 try {
1492 this.size = Long.parseLong(parts[0]);
1493 } catch (final NumberFormatException e) {
1494 this.url = URL.tryParse(parts[0]);
1495 }
1496 break;
1497 case 5:
1498 this.runtime = parseInt(parts[4]);
1499 case 4:
1500 this.width = parseInt(parts[2]);
1501 this.height = parseInt(parts[3]);
1502 case 2:
1503 this.url = URL.tryParse(parts[0]);
1504 this.size = Longs.tryParse(parts[1]);
1505 break;
1506 case 3:
1507 this.size = Longs.tryParse(parts[0]);
1508 this.width = parseInt(parts[1]);
1509 this.height = parseInt(parts[2]);
1510 break;
1511 }
1512 }
1513
1514 public boolean isEmpty() {
1515 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1516 }
1517
1518 public long getSize() {
1519 return size == null ? 0 : size;
1520 }
1521
1522 public String getName() {
1523 Element file = getFileElement();
1524 if (file == null) return null;
1525
1526 return file.findChildContent("name", file.getNamespace());
1527 }
1528
1529 public void setName(final String name) {
1530 if (sims == null) toSims();
1531 Element file = getFileElement();
1532
1533 for (Element child : file.getChildren()) {
1534 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1535 file.removeChild(child);
1536 }
1537 }
1538
1539 if (name != null) {
1540 file.addChild("name", file.getNamespace()).setContent(name);
1541 }
1542 }
1543
1544 public String getMediaType() {
1545 Element file = getFileElement();
1546 if (file == null) return null;
1547
1548 return file.findChildContent("media-type", file.getNamespace());
1549 }
1550
1551 public void setMediaType(final String mime) {
1552 if (sims == null) toSims();
1553 Element file = getFileElement();
1554
1555 for (Element child : file.getChildren()) {
1556 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1557 file.removeChild(child);
1558 }
1559 }
1560
1561 if (mime != null) {
1562 file.addChild("media-type", file.getNamespace()).setContent(mime);
1563 }
1564 }
1565
1566 public Element toSims() {
1567 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1568 sims.setAttribute("type", "data");
1569 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1570 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1571
1572 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1573 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1574 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1575 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1576
1577 file.removeChild(file.findChild("size", file.getNamespace()));
1578 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1579
1580 file.removeChild(file.findChild("width", "https://schema.org/"));
1581 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1582
1583 file.removeChild(file.findChild("height", "https://schema.org/"));
1584 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1585
1586 file.removeChild(file.findChild("duration", "https://schema.org/"));
1587 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1588
1589 if (url != null) {
1590 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1591 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1592
1593 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1594 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1595 source.setAttribute("type", "data");
1596 source.setAttribute("uri", url);
1597 }
1598
1599 return sims;
1600 }
1601
1602 protected Element getFileElement() {
1603 Element file = null;
1604 if (sims == null) return file;
1605
1606 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1607 if (mediaSharing == null) return file;
1608 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1609 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1610 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1611 return file;
1612 }
1613
1614 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1615 if (sims == null) toSims();
1616 Element file = getFileElement();
1617
1618 for (Element child : file.getChildren()) {
1619 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1620 file.removeChild(child);
1621 }
1622 }
1623
1624 for (Cid cid : cids) {
1625 file.addChild("hash", "urn:xmpp:hashes:2")
1626 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1627 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1628 }
1629 }
1630
1631 public List<Cid> getCids() {
1632 List<Cid> cids = new ArrayList<>();
1633 Element file = getFileElement();
1634 if (file == null) return cids;
1635
1636 for (Element child : file.getChildren()) {
1637 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1638 try {
1639 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1640 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1641 }
1642 }
1643
1644 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1645
1646 return cids;
1647 }
1648
1649 public void addThumbnail(int width, int height, String mimeType, String uri) {
1650 for (Element thumb : getThumbnails()) {
1651 if (uri.equals(thumb.getAttribute("uri"))) return;
1652 }
1653
1654 if (sims == null) toSims();
1655 Element file = getFileElement();
1656 file.addChild(
1657 new Element("thumbnail", "urn:xmpp:thumbs:1")
1658 .setAttribute("width", Integer.toString(width))
1659 .setAttribute("height", Integer.toString(height))
1660 .setAttribute("type", mimeType)
1661 .setAttribute("uri", uri)
1662 );
1663 }
1664
1665 public List<Element> getThumbnails() {
1666 List<Element> thumbs = new ArrayList<>();
1667 Element file = getFileElement();
1668 if (file == null) return thumbs;
1669
1670 for (Element child : file.getChildren()) {
1671 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1672 thumbs.add(child);
1673 }
1674 }
1675
1676 return thumbs;
1677 }
1678
1679 public String toString() {
1680 final StringBuilder builder = new StringBuilder();
1681 if (url != null) builder.append(url);
1682 if (size != null) builder.append('|').append(size.toString());
1683 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1684 if (height > 0 || runtime > 0) builder.append('|').append(height);
1685 if (runtime > 0) builder.append('|').append(runtime);
1686 return builder.toString();
1687 }
1688
1689 public boolean equals(Object o) {
1690 if (!(o instanceof FileParams)) return false;
1691 if (url == null) return false;
1692
1693 return url.equals(((FileParams) o).url);
1694 }
1695
1696 public int hashCode() {
1697 return url == null ? super.hashCode() : url.hashCode();
1698 }
1699 }
1700
1701 public void setFingerprint(String fingerprint) {
1702 this.axolotlFingerprint = fingerprint;
1703 }
1704
1705 public String getFingerprint() {
1706 return axolotlFingerprint;
1707 }
1708
1709 public boolean isTrusted() {
1710 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1711 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1712 return s != null && s.isTrusted();
1713 }
1714
1715 private int getPreviousEncryption() {
1716 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1717 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1718 continue;
1719 }
1720 return iterator.getEncryption();
1721 }
1722 return ENCRYPTION_NONE;
1723 }
1724
1725 private int getNextEncryption() {
1726 if (this.conversation instanceof Conversation) {
1727 Conversation conversation = (Conversation) this.conversation;
1728 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1729 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1730 continue;
1731 }
1732 return iterator.getEncryption();
1733 }
1734 return conversation.getNextEncryption();
1735 } else {
1736 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1737 }
1738 }
1739
1740 public boolean isValidInSession() {
1741 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1742 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1743
1744 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1745 || futureEncryption == ENCRYPTION_NONE
1746 || pastEncryption != futureEncryption;
1747
1748 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1749 }
1750
1751 private static int getCleanedEncryption(int encryption) {
1752 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1753 return ENCRYPTION_PGP;
1754 }
1755 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1756 return ENCRYPTION_AXOLOTL;
1757 }
1758 return encryption;
1759 }
1760
1761 public static boolean configurePrivateMessage(final Message message) {
1762 return configurePrivateMessage(message, false);
1763 }
1764
1765 public static boolean configurePrivateFileMessage(final Message message) {
1766 return configurePrivateMessage(message, true);
1767 }
1768
1769 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1770 final Conversation conversation;
1771 if (message.conversation instanceof Conversation) {
1772 conversation = (Conversation) message.conversation;
1773 } else {
1774 return false;
1775 }
1776 if (conversation.getMode() == Conversation.MODE_MULTI) {
1777 final Jid nextCounterpart = conversation.getNextCounterpart();
1778 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1779 }
1780 return false;
1781 }
1782
1783 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1784 final Conversation conversation;
1785 if (message.conversation instanceof Conversation) {
1786 conversation = (Conversation) message.conversation;
1787 } else {
1788 return false;
1789 }
1790 return configurePrivateMessage(conversation, message, counterpart, false);
1791 }
1792
1793 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1794 if (counterpart == null) {
1795 return false;
1796 }
1797 message.setCounterpart(counterpart);
1798 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1799 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1800 return true;
1801 }
1802
1803 public static class PlainTextSpan {}
1804}