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