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 setBody(String body) {
518 if (body == null) {
519 throw new Error("You should not set the message body to null");
520 }
521 this.body = body;
522 this.isGeoUri = null;
523 this.isEmojisOnly = null;
524 this.treatAsDownloadable = null;
525 }
526
527 public synchronized void appendBody(String append) {
528 this.body += append;
529 this.isGeoUri = null;
530 this.isEmojisOnly = null;
531 this.treatAsDownloadable = null;
532 }
533
534 public String getSubject() {
535 return subject;
536 }
537
538 public synchronized void setSubject(String subject) {
539 this.subject = subject;
540 }
541
542 public Element getThread() {
543 if (this.payloads == null) return null;
544
545 for (Element el : this.payloads) {
546 if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
547 return el;
548 }
549 }
550
551 return null;
552 }
553
554 public void setThread(Element thread) {
555 payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
556 addPayload(thread);
557 }
558
559 public void setMucUser(MucOptions.User user) {
560 this.user = new WeakReference<>(user);
561 }
562
563 public boolean sameMucUser(Message otherMessage) {
564 final MucOptions.User thisUser = this.user == null ? null : this.user.get();
565 final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
566 return thisUser != null && thisUser == otherUser;
567 }
568
569 public String getErrorMessage() {
570 return errorMessage;
571 }
572
573 public boolean setErrorMessage(String message) {
574 boolean changed = (message != null && !message.equals(errorMessage))
575 || (message == null && errorMessage != null);
576 this.errorMessage = message;
577 return changed;
578 }
579
580 public long getTimeReceived() {
581 return timeReceived;
582 }
583
584 public long getTimeSent() {
585 return timeSent;
586 }
587
588 public int getEncryption() {
589 return encryption;
590 }
591
592 public void setEncryption(int encryption) {
593 this.encryption = encryption;
594 }
595
596 public int getStatus() {
597 return status;
598 }
599
600 public void setStatus(int status) {
601 this.status = status;
602 }
603
604 public String getRelativeFilePath() {
605 return this.relativeFilePath;
606 }
607
608 public void setRelativeFilePath(String path) {
609 this.relativeFilePath = path;
610 }
611
612 public String getRemoteMsgId() {
613 return this.remoteMsgId;
614 }
615
616 public void setRemoteMsgId(String id) {
617 this.remoteMsgId = id;
618 }
619
620 public String getServerMsgId() {
621 return this.serverMsgId;
622 }
623
624 public void setServerMsgId(String id) {
625 this.serverMsgId = id;
626 }
627
628 public boolean isRead() {
629 return this.read;
630 }
631
632 public boolean isDeleted() {
633 return this.deleted;
634 }
635
636 public Element getModerated() {
637 if (this.payloads == null) return null;
638
639 for (Element el : this.payloads) {
640 if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
641 return el;
642 }
643 }
644
645 return null;
646 }
647
648 public void setDeleted(boolean deleted) {
649 this.deleted = deleted;
650 }
651
652 public void markRead() {
653 this.read = true;
654 }
655
656 public void markUnread() {
657 this.read = false;
658 }
659
660 public void setTime(long time) {
661 this.timeSent = time;
662 }
663
664 public void setTimeReceived(long time) {
665 this.timeReceived = time;
666 }
667
668 public String getEncryptedBody() {
669 return this.encryptedBody;
670 }
671
672 public void setEncryptedBody(String body) {
673 this.encryptedBody = body;
674 }
675
676 public int getType() {
677 return this.type;
678 }
679
680 public void setType(int type) {
681 this.type = type;
682 }
683
684 public boolean isCarbon() {
685 return carbon;
686 }
687
688 public void setCarbon(boolean carbon) {
689 this.carbon = carbon;
690 }
691
692 public void putEdited(String edited, String serverMsgId) {
693 final Edit edit = new Edit(edited, serverMsgId);
694 if (this.edits.size() < 128 && !this.edits.contains(edit)) {
695 this.edits.add(edit);
696 }
697 }
698
699 boolean remoteMsgIdMatchInEdit(String id) {
700 for (Edit edit : this.edits) {
701 if (id.equals(edit.getEditedId())) {
702 return true;
703 }
704 }
705 return false;
706 }
707
708 public String getBodyLanguage() {
709 return this.bodyLanguage;
710 }
711
712 public void setBodyLanguage(String language) {
713 this.bodyLanguage = language;
714 }
715
716 public boolean edited() {
717 return this.edits.size() > 0;
718 }
719
720 public void setTrueCounterpart(Jid trueCounterpart) {
721 this.trueCounterpart = trueCounterpart;
722 }
723
724 public Jid getTrueCounterpart() {
725 return this.trueCounterpart;
726 }
727
728 public Transferable getTransferable() {
729 return this.transferable;
730 }
731
732 public synchronized void setTransferable(Transferable transferable) {
733 this.transferable = transferable;
734 }
735
736 public boolean addReadByMarker(ReadByMarker readByMarker) {
737 if (readByMarker.getRealJid() != null) {
738 if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
739 return false;
740 }
741 } else if (readByMarker.getFullJid() != null) {
742 if (readByMarker.getFullJid().equals(counterpart)) {
743 return false;
744 }
745 }
746 if (this.readByMarkers.add(readByMarker)) {
747 if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
748 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
749 while (iterator.hasNext()) {
750 ReadByMarker marker = iterator.next();
751 if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
752 iterator.remove();
753 }
754 }
755 }
756 return true;
757 } else {
758 return false;
759 }
760 }
761
762 public Set<ReadByMarker> getReadByMarkers() {
763 return ImmutableSet.copyOf(this.readByMarkers);
764 }
765
766 boolean similar(Message message) {
767 if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
768 return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
769 } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
770 return true;
771 } else if (this.body == null || this.counterpart == null) {
772 return false;
773 } else {
774 String body, otherBody;
775 if (this.hasFileOnRemoteHost()) {
776 body = getFileParams().url;
777 otherBody = message.body == null ? null : message.body.trim();
778 } else {
779 body = this.body;
780 otherBody = message.body;
781 }
782 final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
783 if (message.getRemoteMsgId() != null) {
784 final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
785 if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
786 return true;
787 }
788 return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
789 && matchingCounterpart
790 && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
791 } else {
792 return this.remoteMsgId == null
793 && matchingCounterpart
794 && body.equals(otherBody)
795 && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
796 }
797 }
798 }
799
800 public Message next() {
801 if (this.conversation instanceof Conversation) {
802 final Conversation conversation = (Conversation) this.conversation;
803 synchronized (conversation.messages) {
804 if (this.mNextMessage == null) {
805 int index = conversation.messages.indexOf(this);
806 if (index < 0 || index >= conversation.messages.size() - 1) {
807 this.mNextMessage = null;
808 } else {
809 this.mNextMessage = conversation.messages.get(index + 1);
810 }
811 }
812 return this.mNextMessage;
813 }
814 } else {
815 throw new AssertionError("Calling next should be disabled for stubs");
816 }
817 }
818
819 public Message prev() {
820 if (this.conversation instanceof Conversation) {
821 final Conversation conversation = (Conversation) this.conversation;
822 synchronized (conversation.messages) {
823 if (this.mPreviousMessage == null) {
824 int index = conversation.messages.indexOf(this);
825 if (index <= 0 || index > conversation.messages.size()) {
826 this.mPreviousMessage = null;
827 } else {
828 this.mPreviousMessage = conversation.messages.get(index - 1);
829 }
830 }
831 }
832 return this.mPreviousMessage;
833 } else {
834 throw new AssertionError("Calling prev should be disabled for stubs");
835 }
836 }
837
838 public boolean isLastCorrectableMessage() {
839 Message next = next();
840 while (next != null) {
841 if (next.isEditable()) {
842 return false;
843 }
844 next = next.next();
845 }
846 return isEditable();
847 }
848
849 public boolean isEditable() {
850 return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
851 }
852
853 public boolean mergeable(final Message message) {
854 return message != null &&
855 (message.getType() == Message.TYPE_TEXT &&
856 this.getTransferable() == null &&
857 message.getTransferable() == null &&
858 message.getEncryption() != Message.ENCRYPTION_PGP &&
859 message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
860 this.getType() == message.getType() &&
861 this.getSubject() != null &&
862 isStatusMergeable(this.getStatus(), message.getStatus()) &&
863 isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
864 this.getCounterpart() != null &&
865 this.getCounterpart().equals(message.getCounterpart()) &&
866 this.edited() == message.edited() &&
867 (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
868 this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
869 !message.isGeoUri() &&
870 !this.isGeoUri() &&
871 !message.isOOb() &&
872 !this.isOOb() &&
873 !message.treatAsDownloadable() &&
874 !this.treatAsDownloadable() &&
875 !message.hasMeCommand() &&
876 !this.hasMeCommand() &&
877 !this.bodyIsOnlyEmojis() &&
878 !message.bodyIsOnlyEmojis() &&
879 ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
880 UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
881 this.getReadByMarkers().equals(message.getReadByMarkers()) &&
882 !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
883 );
884 }
885
886 private static boolean isStatusMergeable(int a, int b) {
887 return a == b || (
888 (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
889 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
890 || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
891 || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
892 || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
893 );
894 }
895
896 private static boolean isEncryptionMergeable(final int a, final int b) {
897 return a == b
898 && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
899 .contains(a);
900 }
901
902 public void setCounterparts(List<MucOptions.User> counterparts) {
903 this.counterparts = counterparts;
904 }
905
906 public List<MucOptions.User> getCounterparts() {
907 return this.counterparts;
908 }
909
910 @Override
911 public int getAvatarBackgroundColor() {
912 if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
913 return Color.TRANSPARENT;
914 } else {
915 return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
916 }
917 }
918
919 @Override
920 public String getAvatarName() {
921 return UIHelper.getMessageDisplayName(this);
922 }
923
924 public boolean isOOb() {
925 return oob || getFileParams().url != null;
926 }
927
928 public static class MergeSeparator {
929 }
930
931 public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
932 final Element html = getHtml();
933 if (html == null || Build.VERSION.SDK_INT < 24) {
934 return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
935 } else {
936 SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
937 MessageUtils.filterLtrRtl(html.toString()).trim(),
938 Html.FROM_HTML_MODE_COMPACT,
939 (source) -> {
940 try {
941 if (thumbnailer == null) return fallbackImg;
942 Cid cid = BobTransfer.cid(new URI(source));
943 if (cid == null) return fallbackImg;
944 Drawable thumbnail = thumbnailer.getThumbnail(cid);
945 if (thumbnail == null) return fallbackImg;
946 return thumbnail;
947 } catch (final URISyntaxException e) {
948 return fallbackImg;
949 }
950 },
951 (opening, tag, output, xmlReader) -> {}
952 ));
953
954 // Make images clickable and long-clickable with BetterLinkMovementMethod
955 ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
956 for (ImageSpan span : imageSpans) {
957 final int start = spannable.getSpanStart(span);
958 final int end = spannable.getSpanEnd(span);
959
960 ClickableSpan click_span = new ClickableSpan() {
961 @Override
962 public void onClick(View widget) { }
963 };
964
965 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
966 }
967
968 // https://stackoverflow.com/a/10187511/8611
969 int i = spannable.length();
970 while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
971 return (SpannableStringBuilder) spannable.subSequence(0, i+1);
972 }
973 }
974
975 public SpannableStringBuilder getMergedBody() {
976 return getMergedBody(null, null);
977 }
978
979 public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
980 SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
981 Message current = this;
982 while (current.mergeable(current.next())) {
983 current = current.next();
984 if (current == null) {
985 break;
986 }
987 body.append("\n\n");
988 body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
989 SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
990 body.append(current.getSpannableBody(thumbnailer, fallbackImg));
991 }
992 return body;
993 }
994
995 public boolean hasMeCommand() {
996 return this.body.trim().startsWith(ME_COMMAND);
997 }
998
999 public int getMergedStatus() {
1000 int status = this.status;
1001 Message current = this;
1002 while (current.mergeable(current.next())) {
1003 current = current.next();
1004 if (current == null) {
1005 break;
1006 }
1007 status = current.status;
1008 }
1009 return status;
1010 }
1011
1012 public long getMergedTimeSent() {
1013 long time = this.timeSent;
1014 Message current = this;
1015 while (current.mergeable(current.next())) {
1016 current = current.next();
1017 if (current == null) {
1018 break;
1019 }
1020 time = current.timeSent;
1021 }
1022 return time;
1023 }
1024
1025 public boolean wasMergedIntoPrevious() {
1026 Message prev = this.prev();
1027 return prev != null && prev.mergeable(this);
1028 }
1029
1030 public boolean trusted() {
1031 Contact contact = this.getContact();
1032 return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1033 }
1034
1035 public boolean fixCounterpart() {
1036 final Presences presences = conversation.getContact().getPresences();
1037 if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1038 return true;
1039 } else if (presences.size() >= 1) {
1040 counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1041 return true;
1042 } else {
1043 counterpart = null;
1044 return false;
1045 }
1046 }
1047
1048 public void setUuid(String uuid) {
1049 this.uuid = uuid;
1050 }
1051
1052 public String getEditedId() {
1053 if (edits.size() > 0) {
1054 return edits.get(edits.size() - 1).getEditedId();
1055 } else {
1056 throw new IllegalStateException("Attempting to store unedited message");
1057 }
1058 }
1059
1060 public String getEditedIdWireFormat() {
1061 if (edits.size() > 0) {
1062 return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1063 } else {
1064 throw new IllegalStateException("Attempting to store unedited message");
1065 }
1066 }
1067
1068 public URI getOob() {
1069 final String url = getFileParams().url;
1070 try {
1071 return url == null ? null : new URI(url);
1072 } catch (final URISyntaxException e) {
1073 return null;
1074 }
1075 }
1076
1077 public void clearPayloads() {
1078 this.payloads.clear();
1079 }
1080
1081 public void addPayload(Element el) {
1082 if (el == null) return;
1083
1084 this.payloads.add(el);
1085 }
1086
1087 public List<Element> getPayloads() {
1088 return new ArrayList<>(this.payloads);
1089 }
1090
1091 public List<Element> getFallbacks() {
1092 List<Element> fallbacks = new ArrayList<>();
1093
1094 if (this.payloads == null) return fallbacks;
1095
1096 for (Element el : this.payloads) {
1097 if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1098 final String fallbackFor = el.getAttribute("for");
1099 if (fallbackFor == null) continue;
1100 if (fallbackFor.equals("http://jabber.org/protocol/address") || fallbackFor.equals(Namespace.OOB)) {
1101 fallbacks.add(el);
1102 }
1103 }
1104 }
1105
1106 return fallbacks;
1107 }
1108
1109 public Element getHtml() {
1110 if (this.payloads == null) return null;
1111
1112 for (Element el : this.payloads) {
1113 if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1114 return el.getChildren().get(0);
1115 }
1116 }
1117
1118 return null;
1119 }
1120
1121 public List<Element> getCommands() {
1122 if (this.payloads == null) return null;
1123
1124 for (Element el : this.payloads) {
1125 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1126 return el.getChildren();
1127 }
1128 }
1129
1130 return null;
1131 }
1132
1133 public String getMimeType() {
1134 String extension;
1135 if (relativeFilePath != null) {
1136 extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1137 } else {
1138 final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1139 if (url == null) {
1140 return null;
1141 }
1142 extension = MimeUtils.extractRelevantExtension(url);
1143 }
1144 return MimeUtils.guessMimeTypeFromExtension(extension);
1145 }
1146
1147 public synchronized boolean treatAsDownloadable() {
1148 if (treatAsDownloadable == null) {
1149 treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1150 }
1151 return treatAsDownloadable;
1152 }
1153
1154 public synchronized boolean bodyIsOnlyEmojis() {
1155 if (isEmojisOnly == null) {
1156 isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1157 }
1158 return isEmojisOnly;
1159 }
1160
1161 public synchronized boolean isGeoUri() {
1162 if (isGeoUri == null) {
1163 isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1164 }
1165 return isGeoUri;
1166 }
1167
1168 protected List<Element> getSims() {
1169 return payloads.stream().filter(el ->
1170 el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1171 el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1172 ).collect(Collectors.toList());
1173 }
1174
1175 public synchronized void resetFileParams() {
1176 this.fileParams = null;
1177 }
1178
1179 public synchronized void setFileParams(FileParams fileParams) {
1180 if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1181 fileParams.sims = this.fileParams.sims;
1182 }
1183 this.fileParams = fileParams;
1184 if (fileParams != null && getSims().isEmpty()) {
1185 addPayload(fileParams.toSims());
1186 }
1187 }
1188
1189 public synchronized FileParams getFileParams() {
1190 if (fileParams == null) {
1191 List<Element> sims = getSims();
1192 fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1193 if (this.transferable != null) {
1194 fileParams.size = this.transferable.getFileSize();
1195 }
1196 }
1197
1198 return fileParams;
1199 }
1200
1201 private static int parseInt(String value) {
1202 try {
1203 return Integer.parseInt(value);
1204 } catch (NumberFormatException e) {
1205 return 0;
1206 }
1207 }
1208
1209 public void untie() {
1210 this.mNextMessage = null;
1211 this.mPreviousMessage = null;
1212 }
1213
1214 public boolean isPrivateMessage() {
1215 return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1216 }
1217
1218 public boolean isFileOrImage() {
1219 return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1220 }
1221
1222
1223 public boolean isTypeText() {
1224 return type == TYPE_TEXT || type == TYPE_PRIVATE;
1225 }
1226
1227 public boolean hasFileOnRemoteHost() {
1228 return isFileOrImage() && getFileParams().url != null;
1229 }
1230
1231 public boolean needsUploading() {
1232 return isFileOrImage() && getFileParams().url == null;
1233 }
1234
1235 public static class FileParams {
1236 public String url;
1237 public Long size = null;
1238 public int width = 0;
1239 public int height = 0;
1240 public int runtime = 0;
1241 public Element sims = null;
1242
1243 public FileParams() { }
1244
1245 public FileParams(Element el) {
1246 if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1247 this.url = el.findChildContent("url", Namespace.OOB);
1248 }
1249 if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1250 sims = el;
1251 final String refUri = el.getAttribute("uri");
1252 if (refUri != null) url = refUri;
1253 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1254 if (mediaSharing != null) {
1255 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1256 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1257 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1258 if (file != null) {
1259 String sizeS = file.findChildContent("size", file.getNamespace());
1260 if (sizeS != null) size = new Long(sizeS);
1261 String widthS = file.findChildContent("width", "https://schema.org/");
1262 if (widthS != null) width = parseInt(widthS);
1263 String heightS = file.findChildContent("height", "https://schema.org/");
1264 if (heightS != null) height = parseInt(heightS);
1265 String durationS = file.findChildContent("duration", "https://schema.org/");
1266 if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1267 }
1268
1269 final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1270 if (sources != null) {
1271 final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1272 if (ref != null) url = ref.getAttribute("uri");
1273 }
1274 }
1275 }
1276 }
1277
1278 public FileParams(String ser) {
1279 final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1280 switch (parts.length) {
1281 case 1:
1282 try {
1283 this.size = Long.parseLong(parts[0]);
1284 } catch (final NumberFormatException e) {
1285 this.url = URL.tryParse(parts[0]);
1286 }
1287 break;
1288 case 5:
1289 this.runtime = parseInt(parts[4]);
1290 case 4:
1291 this.width = parseInt(parts[2]);
1292 this.height = parseInt(parts[3]);
1293 case 2:
1294 this.url = URL.tryParse(parts[0]);
1295 this.size = Longs.tryParse(parts[1]);
1296 break;
1297 case 3:
1298 this.size = Longs.tryParse(parts[0]);
1299 this.width = parseInt(parts[1]);
1300 this.height = parseInt(parts[2]);
1301 break;
1302 }
1303 }
1304
1305 public long getSize() {
1306 return size == null ? 0 : size;
1307 }
1308
1309 public String getName() {
1310 Element file = getFileElement();
1311 if (file == null) return null;
1312
1313 return file.findChildContent("name", file.getNamespace());
1314 }
1315
1316 public void setName(final String name) {
1317 if (sims == null) toSims();
1318 Element file = getFileElement();
1319
1320 for (Element child : file.getChildren()) {
1321 if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1322 file.removeChild(child);
1323 }
1324 }
1325
1326 if (name != null) {
1327 file.addChild("name", file.getNamespace()).setContent(name);
1328 }
1329 }
1330
1331 public Element toSims() {
1332 if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1333 sims.setAttribute("type", "data");
1334 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1335 if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1336
1337 Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1338 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1339 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1340 if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1341
1342 file.removeChild(file.findChild("size", file.getNamespace()));
1343 if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1344
1345 file.removeChild(file.findChild("width", "https://schema.org/"));
1346 if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1347
1348 file.removeChild(file.findChild("height", "https://schema.org/"));
1349 if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1350
1351 file.removeChild(file.findChild("duration", "https://schema.org/"));
1352 if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1353
1354 if (url != null) {
1355 Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1356 if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1357
1358 Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1359 if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1360 source.setAttribute("type", "data");
1361 source.setAttribute("uri", url);
1362 }
1363
1364 return sims;
1365 }
1366
1367 protected Element getFileElement() {
1368 Element file = null;
1369 if (sims == null) return file;
1370
1371 Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1372 if (mediaSharing == null) return file;
1373 file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1374 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1375 if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1376 return file;
1377 }
1378
1379 public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1380 if (sims == null) toSims();
1381 Element file = getFileElement();
1382
1383 for (Element child : file.getChildren()) {
1384 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1385 file.removeChild(child);
1386 }
1387 }
1388
1389 for (Cid cid : cids) {
1390 file.addChild("hash", "urn:xmpp:hashes:2")
1391 .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1392 .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1393 }
1394 }
1395
1396 public List<Cid> getCids() {
1397 List<Cid> cids = new ArrayList<>();
1398 Element file = getFileElement();
1399 if (file == null) return cids;
1400
1401 for (Element child : file.getChildren()) {
1402 if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1403 try {
1404 cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1405 } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1406 }
1407 }
1408
1409 cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1410
1411 return cids;
1412 }
1413
1414 public List<Element> getThumbnails() {
1415 List<Element> thumbs = new ArrayList<>();
1416 Element file = getFileElement();
1417 if (file == null) return thumbs;
1418
1419 for (Element child : file.getChildren()) {
1420 if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1421 thumbs.add(child);
1422 }
1423 }
1424
1425 return thumbs;
1426 }
1427
1428 public String toString() {
1429 final StringBuilder builder = new StringBuilder();
1430 if (url != null) builder.append(url);
1431 if (size != null) builder.append('|').append(size.toString());
1432 if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1433 if (height > 0 || runtime > 0) builder.append('|').append(height);
1434 if (runtime > 0) builder.append('|').append(runtime);
1435 return builder.toString();
1436 }
1437
1438 public boolean equals(Object o) {
1439 if (!(o instanceof FileParams)) return false;
1440 if (url == null) return false;
1441
1442 return url.equals(((FileParams) o).url);
1443 }
1444
1445 public int hashCode() {
1446 return url == null ? super.hashCode() : url.hashCode();
1447 }
1448 }
1449
1450 public void setFingerprint(String fingerprint) {
1451 this.axolotlFingerprint = fingerprint;
1452 }
1453
1454 public String getFingerprint() {
1455 return axolotlFingerprint;
1456 }
1457
1458 public boolean isTrusted() {
1459 final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1460 final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1461 return s != null && s.isTrusted();
1462 }
1463
1464 private int getPreviousEncryption() {
1465 for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1466 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1467 continue;
1468 }
1469 return iterator.getEncryption();
1470 }
1471 return ENCRYPTION_NONE;
1472 }
1473
1474 private int getNextEncryption() {
1475 if (this.conversation instanceof Conversation) {
1476 Conversation conversation = (Conversation) this.conversation;
1477 for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1478 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1479 continue;
1480 }
1481 return iterator.getEncryption();
1482 }
1483 return conversation.getNextEncryption();
1484 } else {
1485 throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1486 }
1487 }
1488
1489 public boolean isValidInSession() {
1490 int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1491 int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1492
1493 boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1494 || futureEncryption == ENCRYPTION_NONE
1495 || pastEncryption != futureEncryption;
1496
1497 return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1498 }
1499
1500 private static int getCleanedEncryption(int encryption) {
1501 if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1502 return ENCRYPTION_PGP;
1503 }
1504 if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1505 return ENCRYPTION_AXOLOTL;
1506 }
1507 return encryption;
1508 }
1509
1510 public static boolean configurePrivateMessage(final Message message) {
1511 return configurePrivateMessage(message, false);
1512 }
1513
1514 public static boolean configurePrivateFileMessage(final Message message) {
1515 return configurePrivateMessage(message, true);
1516 }
1517
1518 private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1519 final Conversation conversation;
1520 if (message.conversation instanceof Conversation) {
1521 conversation = (Conversation) message.conversation;
1522 } else {
1523 return false;
1524 }
1525 if (conversation.getMode() == Conversation.MODE_MULTI) {
1526 final Jid nextCounterpart = conversation.getNextCounterpart();
1527 return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1528 }
1529 return false;
1530 }
1531
1532 public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1533 final Conversation conversation;
1534 if (message.conversation instanceof Conversation) {
1535 conversation = (Conversation) message.conversation;
1536 } else {
1537 return false;
1538 }
1539 return configurePrivateMessage(conversation, message, counterpart, false);
1540 }
1541
1542 private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1543 if (counterpart == null) {
1544 return false;
1545 }
1546 message.setCounterpart(counterpart);
1547 message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1548 message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1549 return true;
1550 }
1551}