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