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