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