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