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