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