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