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