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