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