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