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