Message.java

   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}