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