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