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