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