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