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