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