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 spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
1020 } else {
1021 boolean[] anyfallbackimg = new boolean[]{ false };
1022
1023 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1024 MessageUtils.filterLtrRtl(html.toString()).trim(),
1025 Html.FROM_HTML_MODE_COMPACT,
1026 (source) -> {
1027 try {
1028 if (thumbnailer == null || source == null) {
1029 anyfallbackimg[0] = true;
1030 return fallbackImg;
1031 }
1032 Cid cid = BobTransfer.cid(new URI(source));
1033 if (cid == null) {
1034 anyfallbackimg[0] = true;
1035 return fallbackImg;
1036 }
1037 Drawable thumbnail = thumbnailer.getThumbnail(cid);
1038 if (thumbnail == null) {
1039 anyfallbackimg[0] = true;
1040 return fallbackImg;
1041 }
1042 return thumbnail;
1043 } catch (final URISyntaxException e) {
1044 anyfallbackimg[0] = true;
1045 return fallbackImg;
1046 }
1047 },
1048 (opening, tag, output, xmlReader) -> {}
1049 ));
1050
1051 // Make images clickable and long-clickable with BetterLinkMovementMethod
1052 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1053 for (ImageSpan span : imageSpans) {
1054 final int start = spannable.getSpanStart(span);
1055 final int end = spannable.getSpanEnd(span);
1056
1057 ClickableSpan click_span = new ClickableSpan() {
1058 @Override
1059 public void onClick(View widget) { }
1060 };
1061
1062 spannable.removeSpan(span);
1063 spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1064 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1065 }
1066
1067 // https://stackoverflow.com/a/10187511/8611
1068 int i = spannable.length();
1069 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1070 if (anyfallbackimg[0]) return (SpannableStringBuilder) spannable.subSequence(0, i+1);
1071 spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1072 }
1073
1074 return new SpannableStringBuilder(spannableBody);
1075 }
1076
1077 public SpannableStringBuilder getMergedBody() {
1078 return getMergedBody(null, null);
1079 }
1080
1081 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1082 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1083 Message current = this;
1084 while (current.mergeable(current.next())) {
1085 current = current.next();
1086 if (current == null || current.getModerated() != null) {
1087 break;
1088 }
1089 body.append("\n\n");
1090 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1091 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1092 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1093 }
1094 return body;
1095 }
1096
1097 public boolean hasMeCommand() {
1098 return this.body.trim().startsWith(ME_COMMAND);
1099 }
1100
1101 public int getMergedStatus() {
1102 int status = this.status;
1103 Message current = this;
1104 while (current.mergeable(current.next())) {
1105 current = current.next();
1106 if (current == null) {
1107 break;
1108 }
1109 status = current.status;
1110 }
1111 return status;
1112 }
1113
1114 public long getMergedTimeSent() {
1115 long time = this.timeSent;
1116 Message current = this;
1117 while (current.mergeable(current.next())) {
1118 current = current.next();
1119 if (current == null) {
1120 break;
1121 }
1122 time = current.timeSent;
1123 }
1124 return time;
1125 }
1126
1127 public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1128 Message prev = this.prev();
1129 if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1130 if (getOccupantId() != null && xmppConnectionService != null) {
1131 final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1132 if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1133 }
1134 return prev != null && prev.mergeable(this);
1135 }
1136
1137 public boolean trusted() {
1138 Contact contact = this.getContact();
1139 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1140 }
1141
1142 public boolean fixCounterpart() {
1143 final Presences presences = conversation.getContact().getPresences();
1144 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1145 return true;
1146 } else if (presences.size() >= 1) {
1147 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1148 return true;
1149 } else {
1150 counterpart = null;
1151 return false;
1152 }
1153 }
1154
1155 public void setUuid(String uuid) {
1156 this.uuid = uuid;
1157 }
1158
1159 public String getEditedId() {
1160 if (edits.size() > 0) {
1161 return edits.get(edits.size() - 1).getEditedId();
1162 } else {
1163 throw new IllegalStateException("Attempting to store unedited message");
1164 }
1165 }
1166
1167 public String getEditedIdWireFormat() {
1168 if (edits.size() > 0) {
1169 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1170 } else {
1171 throw new IllegalStateException("Attempting to store unedited message");
1172 }
1173 }
1174
1175 public List<URI> getLinks() {
1176 SpannableStringBuilder text = new SpannableStringBuilder(
1177 getBody().replaceAll("^>.*", "") // Remove quotes
1178 );
1179 return MyLinkify.extractLinks(text).stream().map((url) -> {
1180 try {
1181 return new URI(url);
1182 } catch (final URISyntaxException e) {
1183 return null;
1184 }
1185 }).filter(x -> x != null).collect(Collectors.toList());
1186 }
1187
1188 public URI getOob() {
1189 final String url = getFileParams().url;
1190 try {
1191 return url == null ? null : new URI(url);
1192 } catch (final URISyntaxException e) {
1193 return null;
1194 }
1195 }
1196
1197 public void clearPayloads() {
1198 this.payloads.clear();
1199 }
1200
1201 public void addPayload(Element el) {
1202 if (el == null) return;
1203
1204 this.payloads.add(el);
1205 }
1206
1207 public List<Element> getPayloads() {
1208 return new ArrayList<>(this.payloads);
1209 }
1210
1211 public List<Element> getFallbacks(String... includeFor) {
1212 List<Element> fallbacks = new ArrayList<>();
1213
1214 if (this.payloads == null) return fallbacks;
1215
1216 for (Element el : this.payloads) {
1217 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1218 final String fallbackFor = el.getAttribute("for");
1219 if (fallbackFor == null) continue;
1220 for (String includeOne : includeFor) {
1221 if (fallbackFor.equals(includeOne)) {
1222 fallbacks.add(el);
1223 break;
1224 }
1225 }
1226 }
1227 }
1228
1229 return fallbacks;
1230 }
1231
1232 public Element getHtml() {
1233 return getHtml(false);
1234 }
1235
1236 public Element getHtml(boolean root) {
1237 if (this.payloads == null) return null;
1238
1239 for (Element el : this.payloads) {
1240 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1241 return root ? el : el.getChildren().get(0);
1242 }
1243 }
1244
1245 return null;
1246 }
1247
1248 public List<Element> getCommands() {
1249 if (this.payloads == null) return null;
1250
1251 for (Element el : this.payloads) {
1252 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1253 return el.getChildren();
1254 }
1255 }
1256
1257 return null;
1258 }
1259
1260 public String getMimeType() {
1261 String extension;
1262 if (relativeFilePath != null) {
1263 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1264 } else {
1265 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1266 if (url == null) {
1267 return null;
1268 }
1269 extension = MimeUtils.extractRelevantExtension(url);
1270 }
1271 return MimeUtils.guessMimeTypeFromExtension(extension);
1272 }
1273
1274 public synchronized boolean treatAsDownloadable() {
1275 if (treatAsDownloadable == null) {
1276 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1277 }
1278 return treatAsDownloadable;
1279 }
1280
1281 public synchronized boolean hasCustomEmoji() {
1282 if (getHtml() != null) {
1283 SpannableStringBuilder spannable = getSpannableBody(null, null);
1284 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1285 return imageSpans.length > 0;
1286 }
1287
1288 return false;
1289 }
1290
1291 public synchronized boolean bodyIsOnlyEmojis() {
1292 if (isEmojisOnly == null) {
1293 isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1294 if (isEmojisOnly) return true;
1295
1296 if (getHtml() != null) {
1297 SpannableStringBuilder spannable = getSpannableBody(null, null);
1298 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1299 for (ImageSpan span : imageSpans) {
1300 final int start = spannable.getSpanStart(span);
1301 final int end = spannable.getSpanEnd(span);
1302 spannable.delete(start, end);
1303 }
1304 final String after = spannable.toString().replaceAll("\\s", "");
1305 isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1306 }
1307 }
1308 return isEmojisOnly;
1309 }
1310
1311 public synchronized boolean isGeoUri() {
1312 if (isGeoUri == null) {
1313 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1314 }
1315 return isGeoUri;
1316 }
1317
1318 protected List<Element> getSims() {
1319 return payloads.stream().filter(el ->
1320 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1321 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1322 ).collect(Collectors.toList());
1323 }
1324
1325 public synchronized void resetFileParams() {
1326 this.fileParams = null;
1327 }
1328
1329 public synchronized void setFileParams(FileParams fileParams) {
1330 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1331 fileParams.sims = this.fileParams.sims;
1332 }
1333 this.fileParams = fileParams;
1334 if (fileParams != null && getSims().isEmpty()) {
1335 addPayload(fileParams.toSims());
1336 }
1337 }
1338
1339 public synchronized FileParams getFileParams() {
1340 if (fileParams == null) {
1341 List<Element> sims = getSims();
1342 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1343 if (this.transferable != null) {
1344 fileParams.size = this.transferable.getFileSize();
1345 }
1346 }
1347
1348 return fileParams;
1349 }
1350
1351 private static int parseInt(String value) {
1352 try {
1353 return Integer.parseInt(value);
1354 } catch (NumberFormatException e) {
1355 return 0;
1356 }
1357 }
1358
1359 public void untie() {
1360 this.mNextMessage = null;
1361 this.mPreviousMessage = null;
1362 }
1363
1364 public boolean isPrivateMessage() {
1365 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1366 }
1367
1368 public boolean isFileOrImage() {
1369 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1370 }
1371
1372
1373 public boolean isTypeText() {
1374 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1375 }
1376
1377 public boolean hasFileOnRemoteHost() {
1378 return isFileOrImage() && getFileParams().url != null;
1379 }
1380
1381 public boolean needsUploading() {
1382 return isFileOrImage() && getFileParams().url == null;
1383 }
1384
1385 public static class FileParams {
1386 public String url;
1387 public Long size = null;
1388 public int width = 0;
1389 public int height = 0;
1390 public int runtime = 0;
1391 public Element sims = null;
1392
1393 public FileParams() { }
1394
1395 public FileParams(Element el) {
1396 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1397 this.url = el.findChildContent("url", Namespace.OOB);
1398 }
1399 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1400 sims = el;
1401 final String refUri = el.getAttribute("uri");
1402 if (refUri != null) url = refUri;
1403 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1404 if (mediaSharing != null) {
1405 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1406 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1407 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1408 if (file != null) {
1409 try {
1410 String sizeS = file.findChildContent("size", file.getNamespace());
1411 if (sizeS != null) size = new Long(sizeS);
1412 String widthS = file.findChildContent("width", "https://schema.org/");
1413 if (widthS != null) width = parseInt(widthS);
1414 String heightS = file.findChildContent("height", "https://schema.org/");
1415 if (heightS != null) height = parseInt(heightS);
1416 String durationS = file.findChildContent("duration", "https://schema.org/");
1417 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1418 } catch (final NumberFormatException e) {
1419 Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1420 }
1421 }
1422
1423 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1424 if (sources != null) {
1425 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1426 if (ref != null) url = ref.getAttribute("uri");
1427 }
1428 }
1429 }
1430 }
1431
1432 public FileParams(String ser) {
1433 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1434 switch (parts.length) {
1435 case 1:
1436 try {
1437 this.size = Long.parseLong(parts[0]);
1438 } catch (final NumberFormatException e) {
1439 this.url = URL.tryParse(parts[0]);
1440 }
1441 break;
1442 case 5:
1443 this.runtime = parseInt(parts[4]);
1444 case 4:
1445 this.width = parseInt(parts[2]);
1446 this.height = parseInt(parts[3]);
1447 case 2:
1448 this.url = URL.tryParse(parts[0]);
1449 this.size = Longs.tryParse(parts[1]);
1450 break;
1451 case 3:
1452 this.size = Longs.tryParse(parts[0]);
1453 this.width = parseInt(parts[1]);
1454 this.height = parseInt(parts[2]);
1455 break;
1456 }
1457 }
1458
1459 public boolean isEmpty() {
1460 return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1461 }
1462
1463 public long getSize() {
1464 return size == null ? 0 : size;
1465 }
1466
1467 public String getName() {
1468 Element file = getFileElement();
1469 if (file == null) return null;
1470
1471 return file.findChildContent("name", file.getNamespace());
1472 }
1473
1474 public void setName(final String name) {
1475 if (sims == null) toSims();
1476 Element file = getFileElement();
1477
1478 for (Element child : file.getChildren()) {
1479 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1480 file.removeChild(child);
1481 }
1482 }
1483
1484 if (name != null) {
1485 file.addChild("name", file.getNamespace()).setContent(name);
1486 }
1487 }
1488
1489 public String getMediaType() {
1490 Element file = getFileElement();
1491 if (file == null) return null;
1492
1493 return file.findChildContent("media-type", file.getNamespace());
1494 }
1495
1496 public void setMediaType(final String mime) {
1497 if (sims == null) toSims();
1498 Element file = getFileElement();
1499
1500 for (Element child : file.getChildren()) {
1501 if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1502 file.removeChild(child);
1503 }
1504 }
1505
1506 if (mime != null) {
1507 file.addChild("media-type", file.getNamespace()).setContent(mime);
1508 }
1509 }
1510
1511 public Element toSims() {
1512 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1513 sims.setAttribute("type", "data");
1514 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1515 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1516
1517 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1518 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1519 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1520 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1521
1522 file.removeChild(file.findChild("size", file.getNamespace()));
1523 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1524
1525 file.removeChild(file.findChild("width", "https://schema.org/"));
1526 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1527
1528 file.removeChild(file.findChild("height", "https://schema.org/"));
1529 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1530
1531 file.removeChild(file.findChild("duration", "https://schema.org/"));
1532 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1533
1534 if (url != null) {
1535 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1536 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1537
1538 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1539 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1540 source.setAttribute("type", "data");
1541 source.setAttribute("uri", url);
1542 }
1543
1544 return sims;
1545 }
1546
1547 protected Element getFileElement() {
1548 Element file = null;
1549 if (sims == null) return file;
1550
1551 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1552 if (mediaSharing == null) return file;
1553 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1554 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1555 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1556 return file;
1557 }
1558
1559 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1560 if (sims == null) toSims();
1561 Element file = getFileElement();
1562
1563 for (Element child : file.getChildren()) {
1564 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1565 file.removeChild(child);
1566 }
1567 }
1568
1569 for (Cid cid : cids) {
1570 file.addChild("hash", "urn:xmpp:hashes:2")
1571 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1572 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1573 }
1574 }
1575
1576 public List<Cid> getCids() {
1577 List<Cid> cids = new ArrayList<>();
1578 Element file = getFileElement();
1579 if (file == null) return cids;
1580
1581 for (Element child : file.getChildren()) {
1582 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1583 try {
1584 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1585 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1586 }
1587 }
1588
1589 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1590
1591 return cids;
1592 }
1593
1594 public void addThumbnail(int width, int height, String mimeType, String uri) {
1595 for (Element thumb : getThumbnails()) {
1596 if (uri.equals(thumb.getAttribute("uri"))) return;
1597 }
1598
1599 if (sims == null) toSims();
1600 Element file = getFileElement();
1601 file.addChild(
1602 new Element("thumbnail", "urn:xmpp:thumbs:1")
1603 .setAttribute("width", Integer.toString(width))
1604 .setAttribute("height", Integer.toString(height))
1605 .setAttribute("type", mimeType)
1606 .setAttribute("uri", uri)
1607 );
1608 }
1609
1610 public List<Element> getThumbnails() {
1611 List<Element> thumbs = new ArrayList<>();
1612 Element file = getFileElement();
1613 if (file == null) return thumbs;
1614
1615 for (Element child : file.getChildren()) {
1616 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1617 thumbs.add(child);
1618 }
1619 }
1620
1621 return thumbs;
1622 }
1623
1624 public String toString() {
1625 final StringBuilder builder = new StringBuilder();
1626 if (url != null) builder.append(url);
1627 if (size != null) builder.append('|').append(size.toString());
1628 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1629 if (height > 0 || runtime > 0) builder.append('|').append(height);
1630 if (runtime > 0) builder.append('|').append(runtime);
1631 return builder.toString();
1632 }
1633
1634 public boolean equals(Object o) {
1635 if (!(o instanceof FileParams)) return false;
1636 if (url == null) return false;
1637
1638 return url.equals(((FileParams) o).url);
1639 }
1640
1641 public int hashCode() {
1642 return url == null ? super.hashCode() : url.hashCode();
1643 }
1644 }
1645
1646 public void setFingerprint(String fingerprint) {
1647 this.axolotlFingerprint = fingerprint;
1648 }
1649
1650 public String getFingerprint() {
1651 return axolotlFingerprint;
1652 }
1653
1654 public boolean isTrusted() {
1655 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1656 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1657 return s != null && s.isTrusted();
1658 }
1659
1660 private int getPreviousEncryption() {
1661 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1662 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1663 continue;
1664 }
1665 return iterator.getEncryption();
1666 }
1667 return ENCRYPTION_NONE;
1668 }
1669
1670 private int getNextEncryption() {
1671 if (this.conversation instanceof Conversation) {
1672 Conversation conversation = (Conversation) this.conversation;
1673 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1674 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1675 continue;
1676 }
1677 return iterator.getEncryption();
1678 }
1679 return conversation.getNextEncryption();
1680 } else {
1681 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1682 }
1683 }
1684
1685 public boolean isValidInSession() {
1686 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1687 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1688
1689 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1690 || futureEncryption == ENCRYPTION_NONE
1691 || pastEncryption != futureEncryption;
1692
1693 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1694 }
1695
1696 private static int getCleanedEncryption(int encryption) {
1697 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1698 return ENCRYPTION_PGP;
1699 }
1700 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1701 return ENCRYPTION_AXOLOTL;
1702 }
1703 return encryption;
1704 }
1705
1706 public static boolean configurePrivateMessage(final Message message) {
1707 return configurePrivateMessage(message, false);
1708 }
1709
1710 public static boolean configurePrivateFileMessage(final Message message) {
1711 return configurePrivateMessage(message, true);
1712 }
1713
1714 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1715 final Conversation conversation;
1716 if (message.conversation instanceof Conversation) {
1717 conversation = (Conversation) message.conversation;
1718 } else {
1719 return false;
1720 }
1721 if (conversation.getMode() == Conversation.MODE_MULTI) {
1722 final Jid nextCounterpart = conversation.getNextCounterpart();
1723 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1724 }
1725 return false;
1726 }
1727
1728 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1729 final Conversation conversation;
1730 if (message.conversation instanceof Conversation) {
1731 conversation = (Conversation) message.conversation;
1732 } else {
1733 return false;
1734 }
1735 return configurePrivateMessage(conversation, message, counterpart, false);
1736 }
1737
1738 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1739 if (counterpart == null) {
1740 return false;
1741 }
1742 message.setCounterpart(counterpart);
1743 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1744 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1745 return true;
1746 }
1747
1748 public static class PlainTextSpan {}
1749}