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