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