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