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