Message.java

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