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("http://jabber.org/protocol/address", Namespace.OOB);
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(String... includeFor) {
533 this.payloads.removeAll(getFallbacks(includeFor));
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(String... includeFor) {
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 for (String includeOne : includeFor) {
1117 if (fallbackFor.equals(includeOne)) {
1118 fallbacks.add(el);
1119 break;
1120 }
1121 }
1122 }
1123 }
1124
1125 return fallbacks;
1126 }
1127
1128 public Element getHtml() {
1129 if (this.payloads == null) return null;
1130
1131 for (Element el : this.payloads) {
1132 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1133 return el.getChildren().get(0);
1134 }
1135 }
1136
1137 return null;
1138 }
1139
1140 public List<Element> getCommands() {
1141 if (this.payloads == null) return null;
1142
1143 for (Element el : this.payloads) {
1144 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1145 return el.getChildren();
1146 }
1147 }
1148
1149 return null;
1150 }
1151
1152 public String getMimeType() {
1153 String extension;
1154 if (relativeFilePath != null) {
1155 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1156 } else {
1157 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1158 if (url == null) {
1159 return null;
1160 }
1161 extension = MimeUtils.extractRelevantExtension(url);
1162 }
1163 return MimeUtils.guessMimeTypeFromExtension(extension);
1164 }
1165
1166 public synchronized boolean treatAsDownloadable() {
1167 if (treatAsDownloadable == null) {
1168 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1169 }
1170 return treatAsDownloadable;
1171 }
1172
1173 public synchronized boolean bodyIsOnlyEmojis() {
1174 if (isEmojisOnly == null) {
1175 isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1176 }
1177 return isEmojisOnly;
1178 }
1179
1180 public synchronized boolean isGeoUri() {
1181 if (isGeoUri == null) {
1182 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1183 }
1184 return isGeoUri;
1185 }
1186
1187 protected List<Element> getSims() {
1188 return payloads.stream().filter(el ->
1189 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1190 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1191 ).collect(Collectors.toList());
1192 }
1193
1194 public synchronized void resetFileParams() {
1195 this.fileParams = null;
1196 }
1197
1198 public synchronized void setFileParams(FileParams fileParams) {
1199 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1200 fileParams.sims = this.fileParams.sims;
1201 }
1202 this.fileParams = fileParams;
1203 if (fileParams != null && getSims().isEmpty()) {
1204 addPayload(fileParams.toSims());
1205 }
1206 }
1207
1208 public synchronized FileParams getFileParams() {
1209 if (fileParams == null) {
1210 List<Element> sims = getSims();
1211 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1212 if (this.transferable != null) {
1213 fileParams.size = this.transferable.getFileSize();
1214 }
1215 }
1216
1217 return fileParams;
1218 }
1219
1220 private static int parseInt(String value) {
1221 try {
1222 return Integer.parseInt(value);
1223 } catch (NumberFormatException e) {
1224 return 0;
1225 }
1226 }
1227
1228 public void untie() {
1229 this.mNextMessage = null;
1230 this.mPreviousMessage = null;
1231 }
1232
1233 public boolean isPrivateMessage() {
1234 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1235 }
1236
1237 public boolean isFileOrImage() {
1238 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1239 }
1240
1241
1242 public boolean isTypeText() {
1243 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1244 }
1245
1246 public boolean hasFileOnRemoteHost() {
1247 return isFileOrImage() && getFileParams().url != null;
1248 }
1249
1250 public boolean needsUploading() {
1251 return isFileOrImage() && getFileParams().url == null;
1252 }
1253
1254 public static class FileParams {
1255 public String url;
1256 public Long size = null;
1257 public int width = 0;
1258 public int height = 0;
1259 public int runtime = 0;
1260 public Element sims = null;
1261
1262 public FileParams() { }
1263
1264 public FileParams(Element el) {
1265 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1266 this.url = el.findChildContent("url", Namespace.OOB);
1267 }
1268 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1269 sims = el;
1270 final String refUri = el.getAttribute("uri");
1271 if (refUri != null) url = refUri;
1272 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1273 if (mediaSharing != null) {
1274 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1275 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1276 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1277 if (file != null) {
1278 String sizeS = file.findChildContent("size", file.getNamespace());
1279 if (sizeS != null) size = new Long(sizeS);
1280 String widthS = file.findChildContent("width", "https://schema.org/");
1281 if (widthS != null) width = parseInt(widthS);
1282 String heightS = file.findChildContent("height", "https://schema.org/");
1283 if (heightS != null) height = parseInt(heightS);
1284 String durationS = file.findChildContent("duration", "https://schema.org/");
1285 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1286 }
1287
1288 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1289 if (sources != null) {
1290 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1291 if (ref != null) url = ref.getAttribute("uri");
1292 }
1293 }
1294 }
1295 }
1296
1297 public FileParams(String ser) {
1298 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1299 switch (parts.length) {
1300 case 1:
1301 try {
1302 this.size = Long.parseLong(parts[0]);
1303 } catch (final NumberFormatException e) {
1304 this.url = URL.tryParse(parts[0]);
1305 }
1306 break;
1307 case 5:
1308 this.runtime = parseInt(parts[4]);
1309 case 4:
1310 this.width = parseInt(parts[2]);
1311 this.height = parseInt(parts[3]);
1312 case 2:
1313 this.url = URL.tryParse(parts[0]);
1314 this.size = Longs.tryParse(parts[1]);
1315 break;
1316 case 3:
1317 this.size = Longs.tryParse(parts[0]);
1318 this.width = parseInt(parts[1]);
1319 this.height = parseInt(parts[2]);
1320 break;
1321 }
1322 }
1323
1324 public boolean isEmpty() {
1325 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1326 }
1327
1328 public long getSize() {
1329 return size == null ? 0 : size;
1330 }
1331
1332 public String getName() {
1333 Element file = getFileElement();
1334 if (file == null) return null;
1335
1336 return file.findChildContent("name", file.getNamespace());
1337 }
1338
1339 public void setName(final String name) {
1340 if (sims == null) toSims();
1341 Element file = getFileElement();
1342
1343 for (Element child : file.getChildren()) {
1344 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1345 file.removeChild(child);
1346 }
1347 }
1348
1349 if (name != null) {
1350 file.addChild("name", file.getNamespace()).setContent(name);
1351 }
1352 }
1353
1354 public String getMediaType() {
1355 Element file = getFileElement();
1356 if (file == null) return null;
1357
1358 return file.findChildContent("media-type", file.getNamespace());
1359 }
1360
1361 public void setMediaType(final String mime) {
1362 if (sims == null) toSims();
1363 Element file = getFileElement();
1364
1365 for (Element child : file.getChildren()) {
1366 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1367 file.removeChild(child);
1368 }
1369 }
1370
1371 if (mime != null) {
1372 file.addChild("media-type", file.getNamespace()).setContent(mime);
1373 }
1374 }
1375
1376 public Element toSims() {
1377 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1378 sims.setAttribute("type", "data");
1379 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1380 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1381
1382 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1383 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1384 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1385 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1386
1387 file.removeChild(file.findChild("size", file.getNamespace()));
1388 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1389
1390 file.removeChild(file.findChild("width", "https://schema.org/"));
1391 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1392
1393 file.removeChild(file.findChild("height", "https://schema.org/"));
1394 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1395
1396 file.removeChild(file.findChild("duration", "https://schema.org/"));
1397 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1398
1399 if (url != null) {
1400 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1401 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1402
1403 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1404 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1405 source.setAttribute("type", "data");
1406 source.setAttribute("uri", url);
1407 }
1408
1409 return sims;
1410 }
1411
1412 protected Element getFileElement() {
1413 Element file = null;
1414 if (sims == null) return file;
1415
1416 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1417 if (mediaSharing == null) return file;
1418 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1419 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1420 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1421 return file;
1422 }
1423
1424 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1425 if (sims == null) toSims();
1426 Element file = getFileElement();
1427
1428 for (Element child : file.getChildren()) {
1429 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1430 file.removeChild(child);
1431 }
1432 }
1433
1434 for (Cid cid : cids) {
1435 file.addChild("hash", "urn:xmpp:hashes:2")
1436 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1437 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1438 }
1439 }
1440
1441 public List<Cid> getCids() {
1442 List<Cid> cids = new ArrayList<>();
1443 Element file = getFileElement();
1444 if (file == null) return cids;
1445
1446 for (Element child : file.getChildren()) {
1447 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1448 try {
1449 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1450 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1451 }
1452 }
1453
1454 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1455
1456 return cids;
1457 }
1458
1459 public void addThumbnail(int width, int height, String mimeType, String uri) {
1460 for (Element thumb : getThumbnails()) {
1461 if (uri.equals(thumb.getAttribute("uri"))) return;
1462 }
1463
1464 if (sims == null) toSims();
1465 Element file = getFileElement();
1466 file.addChild(
1467 new Element("thumbnail", "urn:xmpp:thumbs:1")
1468 .setAttribute("width", Integer.toString(width))
1469 .setAttribute("height", Integer.toString(height))
1470 .setAttribute("type", mimeType)
1471 .setAttribute("uri", uri)
1472 );
1473 }
1474
1475 public List<Element> getThumbnails() {
1476 List<Element> thumbs = new ArrayList<>();
1477 Element file = getFileElement();
1478 if (file == null) return thumbs;
1479
1480 for (Element child : file.getChildren()) {
1481 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1482 thumbs.add(child);
1483 }
1484 }
1485
1486 return thumbs;
1487 }
1488
1489 public String toString() {
1490 final StringBuilder builder = new StringBuilder();
1491 if (url != null) builder.append(url);
1492 if (size != null) builder.append('|').append(size.toString());
1493 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1494 if (height > 0 || runtime > 0) builder.append('|').append(height);
1495 if (runtime > 0) builder.append('|').append(runtime);
1496 return builder.toString();
1497 }
1498
1499 public boolean equals(Object o) {
1500 if (!(o instanceof FileParams)) return false;
1501 if (url == null) return false;
1502
1503 return url.equals(((FileParams) o).url);
1504 }
1505
1506 public int hashCode() {
1507 return url == null ? super.hashCode() : url.hashCode();
1508 }
1509 }
1510
1511 public void setFingerprint(String fingerprint) {
1512 this.axolotlFingerprint = fingerprint;
1513 }
1514
1515 public String getFingerprint() {
1516 return axolotlFingerprint;
1517 }
1518
1519 public boolean isTrusted() {
1520 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1521 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1522 return s != null && s.isTrusted();
1523 }
1524
1525 private int getPreviousEncryption() {
1526 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1527 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1528 continue;
1529 }
1530 return iterator.getEncryption();
1531 }
1532 return ENCRYPTION_NONE;
1533 }
1534
1535 private int getNextEncryption() {
1536 if (this.conversation instanceof Conversation) {
1537 Conversation conversation = (Conversation) this.conversation;
1538 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1539 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1540 continue;
1541 }
1542 return iterator.getEncryption();
1543 }
1544 return conversation.getNextEncryption();
1545 } else {
1546 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1547 }
1548 }
1549
1550 public boolean isValidInSession() {
1551 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1552 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1553
1554 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1555 || futureEncryption == ENCRYPTION_NONE
1556 || pastEncryption != futureEncryption;
1557
1558 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1559 }
1560
1561 private static int getCleanedEncryption(int encryption) {
1562 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1563 return ENCRYPTION_PGP;
1564 }
1565 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1566 return ENCRYPTION_AXOLOTL;
1567 }
1568 return encryption;
1569 }
1570
1571 public static boolean configurePrivateMessage(final Message message) {
1572 return configurePrivateMessage(message, false);
1573 }
1574
1575 public static boolean configurePrivateFileMessage(final Message message) {
1576 return configurePrivateMessage(message, true);
1577 }
1578
1579 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1580 final Conversation conversation;
1581 if (message.conversation instanceof Conversation) {
1582 conversation = (Conversation) message.conversation;
1583 } else {
1584 return false;
1585 }
1586 if (conversation.getMode() == Conversation.MODE_MULTI) {
1587 final Jid nextCounterpart = conversation.getNextCounterpart();
1588 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1589 }
1590 return false;
1591 }
1592
1593 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1594 final Conversation conversation;
1595 if (message.conversation instanceof Conversation) {
1596 conversation = (Conversation) message.conversation;
1597 } else {
1598 return false;
1599 }
1600 return configurePrivateMessage(conversation, message, counterpart, false);
1601 }
1602
1603 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1604 if (counterpart == null) {
1605 return false;
1606 }
1607 message.setCounterpart(counterpart);
1608 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1609 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1610 return true;
1611 }
1612}