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