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