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