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