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