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