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