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