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