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 String subject;
 134    protected String encryptedBody;
 135    protected long timeSent;
 136    protected long timeReceived;
 137    protected int encryption;
 138    protected int status;
 139    protected int type;
 140    protected boolean deleted = false;
 141    protected boolean carbon = false;
 142    private boolean oob = false;
 143    protected List<Element> payloads = new ArrayList<>();
 144    protected List<Edit> edits = new ArrayList<>();
 145    protected String relativeFilePath;
 146    protected boolean read = true;
 147    protected String remoteMsgId = null;
 148    private String bodyLanguage = null;
 149    protected String serverMsgId = null;
 150    private final Conversational conversation;
 151    protected Transferable transferable = null;
 152    private Message mNextMessage = null;
 153    private Message mPreviousMessage = null;
 154    private String axolotlFingerprint = null;
 155    private String errorMessage = null;
 156    private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
 157    protected Message mInReplyTo = null;
 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        setInReplyTo(replyTo);
 444    }
 445
 446    public Message react(String emoji) {
 447        final var m = reply();
 448        m.updateReaction(this, emoji);
 449        return m;
 450    }
 451
 452    public void updateReaction(final Message reactTo, String emoji) {
 453         Set<String> emojis = new HashSet<>();
 454        if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(reactTo.replyId(), null);
 455        emojis.remove(getBody(true));
 456        emojis.add(emoji);
 457
 458        updateReplyTo(reactTo, new SpannableStringBuilder(emoji));
 459        final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
 460        fallback.addChild("body", "urn:xmpp:fallback:0");
 461        addPayload(fallback);
 462        final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", replyId());
 463        for (String oneEmoji : emojis) {
 464            reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji);
 465        }
 466        addPayload(reactions);
 467    }
 468
 469    public void setReactions(Element reactions) {
 470        if (this.payloads != null) {
 471            this.payloads.remove(getReactions());
 472        }
 473        addPayload(reactions);
 474    }
 475
 476    public Element getReactions() {
 477        if (this.payloads == null) return null;
 478
 479        for (Element el : this.payloads) {
 480            if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
 481                return el;
 482            }
 483        }
 484
 485        return null;
 486    }
 487
 488    public Element getReply() {
 489        if (this.payloads == null) return null;
 490
 491        for (Element el : this.payloads) {
 492            if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) {
 493                return el;
 494            }
 495        }
 496
 497        return null;
 498    }
 499
 500    public boolean isAttention() {
 501        if (this.payloads == null) return false;
 502
 503        for (Element el : this.payloads) {
 504            if (el.getName().equals("attention") && el.getNamespace().equals("urn:xmpp:attention:0")) {
 505                return true;
 506            }
 507        }
 508
 509        return false;
 510    }
 511
 512    public String getConversationUuid() {
 513        return conversationUuid;
 514    }
 515
 516    public Conversational getConversation() {
 517        return this.conversation;
 518    }
 519
 520    public Jid getCounterpart() {
 521        return counterpart;
 522    }
 523
 524    public void setCounterpart(final Jid counterpart) {
 525        this.counterpart = counterpart;
 526    }
 527
 528    public Contact getContact() {
 529        if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
 530            if (this.trueCounterpart != null) {
 531                return this.conversation.getAccount().getRoster()
 532                           .getContact(this.trueCounterpart);
 533            }
 534
 535            return this.conversation.getContact();
 536        } else {
 537            if (this.trueCounterpart == null) {
 538                return null;
 539            } else {
 540                return this.conversation.getAccount().getRoster()
 541                        .getContactFromContactList(this.trueCounterpart);
 542            }
 543        }
 544    }
 545
 546    public String getQuoteableBody() {
 547        if (this.body == null) return null;
 548
 549        StringBuilder body = bodyMinusFallbacks("http://jabber.org/protocol/address").first;
 550        return body.toString();
 551    }
 552
 553    public String getRawBody() {
 554        return this.body;
 555    }
 556
 557    private Pair<StringBuilder, Boolean> bodyMinusFallbacks(String... fallbackNames) {
 558        StringBuilder body = new StringBuilder(this.body == null ? "" : this.body);
 559
 560        List<Element> fallbacks = getFallbacks(fallbackNames);
 561        List<Pair<Integer, Integer>> spans = new ArrayList<>();
 562        for (Element fallback : fallbacks) {
 563            for (Element span : fallback.getChildren()) {
 564                if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
 565                if (span.getAttribute("start") == null || span.getAttribute("end") == null) return new Pair<>(new StringBuilder(""), true);
 566                spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
 567            }
 568        }
 569        // Do them in reverse order so that span deletions don't affect the indexes of other spans
 570        spans.sort((x, y) -> y.first.compareTo(x.first));
 571        try {
 572            for (Pair<Integer, Integer> span : spans) {
 573                body.delete(body.offsetByCodePoints(0, span.first.intValue()), body.offsetByCodePoints(0, span.second.intValue()));
 574            }
 575        } catch (final IndexOutOfBoundsException e) { spans.clear(); }
 576
 577        return new Pair<>(body, !spans.isEmpty());
 578    }
 579
 580    public String getBody() {
 581        return getBody(false);
 582    }
 583
 584    public String getBody(final boolean removeQuoteFallbacks) {
 585        if (body == null) return "";
 586
 587        Pair<StringBuilder, Boolean> result =
 588            removeQuoteFallbacks
 589            ? bodyMinusFallbacks("http://jabber.org/protocol/address", Namespace.OOB, "urn:xmpp:reply:0")
 590            : bodyMinusFallbacks("http://jabber.org/protocol/address", Namespace.OOB);
 591        StringBuilder body = result.first;
 592
 593        final String aesgcm = MessageUtils.aesgcmDownloadable(body.toString());
 594        if (!result.second && aesgcm != null) {
 595            return body.toString().replace(aesgcm, "");
 596        } else if (!result.second && getOob() != null) {
 597            return body.toString().replace(getOob().toString(), "");
 598        } else if (!result.second && isGeoUri()) {
 599            return "";
 600        } else {
 601            return body.toString();
 602        }
 603    }
 604
 605    public synchronized void clearFallbacks(String... includeFor) {
 606        this.payloads.removeAll(getFallbacks(includeFor));
 607    }
 608
 609    public synchronized Element getOrMakeHtml() {
 610        Element html = getHtml();
 611        if (html != null) return html;
 612        html = new Element("html", "http://jabber.org/protocol/xhtml-im");
 613        Element body = html.addChild("body", "http://www.w3.org/1999/xhtml");
 614        SpannedToXHTML.append(body, new SpannableStringBuilder(getBody(true)));
 615        addPayload(html);
 616        return body;
 617    }
 618
 619    public synchronized void setBody(Spanned span) {
 620        setBody(span == null ? null : span.toString());
 621        if (span == null || SpannedToXHTML.isPlainText(span)) {
 622            this.payloads.remove(getHtml(true));
 623        } else {
 624            final Element body = getOrMakeHtml();
 625            body.clearChildren();
 626            SpannedToXHTML.append(body, span);
 627        }
 628    }
 629
 630    public synchronized void setHtml(Element html) {
 631        final Element oldHtml = getHtml(true);
 632        if (oldHtml != null) this.payloads.remove(oldHtml);
 633        if (html != null) addPayload(html);
 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    }
 642
 643    public synchronized void appendBody(Spanned append) {
 644        if (!SpannedToXHTML.isPlainText(append) || getHtml() != null) {
 645            final Element body = getOrMakeHtml();
 646            SpannedToXHTML.append(body, append);
 647        }
 648        appendBody(append.toString());
 649    }
 650
 651    public synchronized void appendBody(String append) {
 652        this.body += append;
 653        this.isGeoUri = null;
 654        this.isEmojisOnly = null;
 655        this.treatAsDownloadable = null;
 656    }
 657
 658    public String getSubject() {
 659        return subject;
 660    }
 661
 662    public synchronized void setSubject(String subject) {
 663        this.subject = subject;
 664    }
 665
 666    public Element getThread() {
 667        if (this.payloads == null) return null;
 668
 669        for (Element el : this.payloads) {
 670            if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
 671                return el;
 672            }
 673        }
 674
 675        return null;
 676    }
 677
 678    public void setThread(Element thread) {
 679        payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
 680        addPayload(thread);
 681    }
 682
 683    public void setOccupantId(final String id) {
 684        occupantId = id;
 685    }
 686
 687    public String getOccupantId() {
 688        return occupantId;
 689    }
 690
 691    public void setMucUser(MucOptions.User user) {
 692        this.user = new WeakReference<>(user);
 693        if (user != null && user.getOccupantId() != null) setOccupantId(user.getOccupantId());
 694    }
 695
 696    public boolean sameMucUser(Message otherMessage) {
 697        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 698        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 699        return thisUser != null && thisUser == otherUser;
 700    }
 701
 702    public String getErrorMessage() {
 703        return errorMessage;
 704    }
 705
 706    public boolean setErrorMessage(String message) {
 707        boolean changed = (message != null && !message.equals(errorMessage))
 708                || (message == null && errorMessage != null);
 709        this.errorMessage = message;
 710        return changed;
 711    }
 712
 713    public long getTimeReceived() {
 714        return timeReceived;
 715    }
 716
 717    public long getTimeSent() {
 718        return timeSent;
 719    }
 720
 721    public int getEncryption() {
 722        return encryption;
 723    }
 724
 725    public void setEncryption(int encryption) {
 726        this.encryption = encryption;
 727    }
 728
 729    public int getStatus() {
 730        return status;
 731    }
 732
 733    public void setStatus(int status) {
 734        this.status = status;
 735    }
 736
 737    public String getRelativeFilePath() {
 738        return this.relativeFilePath;
 739    }
 740
 741    public void setRelativeFilePath(String path) {
 742        this.relativeFilePath = path;
 743    }
 744
 745    public String getRemoteMsgId() {
 746        return this.remoteMsgId;
 747    }
 748
 749    public void setRemoteMsgId(String id) {
 750        this.remoteMsgId = id;
 751    }
 752
 753    public String getServerMsgId() {
 754        return this.serverMsgId;
 755    }
 756
 757    public void setServerMsgId(String id) {
 758        this.serverMsgId = id;
 759    }
 760
 761    public boolean isRead() {
 762        return this.read;
 763    }
 764
 765    public boolean isDeleted() {
 766        return this.deleted;
 767    }
 768
 769    public Element getModerated() {
 770        if (this.payloads == null) return null;
 771
 772        for (Element el : this.payloads) {
 773            if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
 774                return el;
 775            }
 776        }
 777
 778        return null;
 779    }
 780
 781    public void setDeleted(boolean deleted) {
 782        this.deleted = deleted;
 783    }
 784
 785    public void markRead() {
 786        this.read = true;
 787    }
 788
 789    public void markUnread() {
 790        this.read = false;
 791    }
 792
 793    public void setTime(long time) {
 794        this.timeSent = time;
 795    }
 796
 797    public void setTimeReceived(long time) {
 798        this.timeReceived = time;
 799    }
 800
 801    public String getEncryptedBody() {
 802        return this.encryptedBody;
 803    }
 804
 805    public void setEncryptedBody(String body) {
 806        this.encryptedBody = body;
 807    }
 808
 809    public int getType() {
 810        return this.type;
 811    }
 812
 813    public void setType(int type) {
 814        this.type = type;
 815    }
 816
 817    public boolean isCarbon() {
 818        return carbon;
 819    }
 820
 821    public void setCarbon(boolean carbon) {
 822        this.carbon = carbon;
 823    }
 824
 825    public void putEdited(String edited, String serverMsgId) {
 826        final Edit edit = new Edit(edited, serverMsgId);
 827        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 828            this.edits.add(edit);
 829        }
 830    }
 831
 832    boolean remoteMsgIdMatchInEdit(String id) {
 833        for (Edit edit : this.edits) {
 834            if (id.equals(edit.getEditedId())) {
 835                return true;
 836            }
 837        }
 838        return false;
 839    }
 840
 841    public String getBodyLanguage() {
 842        return this.bodyLanguage;
 843    }
 844
 845    public void setBodyLanguage(String language) {
 846        this.bodyLanguage = language;
 847    }
 848
 849    public boolean edited() {
 850        return this.edits.size() > 0;
 851    }
 852
 853    public void setTrueCounterpart(Jid trueCounterpart) {
 854        this.trueCounterpart = trueCounterpart;
 855    }
 856
 857    public Jid getTrueCounterpart() {
 858        return this.trueCounterpart;
 859    }
 860
 861    public Transferable getTransferable() {
 862        return this.transferable;
 863    }
 864
 865    public synchronized void setTransferable(Transferable transferable) {
 866        this.transferable = transferable;
 867    }
 868
 869    public boolean addReadByMarker(final ReadByMarker readByMarker) {
 870        if (readByMarker.getRealJid() != null) {
 871            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 872                return false;
 873            }
 874        } else if (readByMarker.getFullJid() != null) {
 875            if (readByMarker.getFullJid().equals(counterpart)) {
 876                return false;
 877            }
 878        }
 879        if (this.readByMarkers.add(readByMarker)) {
 880            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 881                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 882                while (iterator.hasNext()) {
 883                    ReadByMarker marker = iterator.next();
 884                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 885                        iterator.remove();
 886                    }
 887                }
 888            }
 889            return true;
 890        } else {
 891            return false;
 892        }
 893    }
 894
 895    public Set<ReadByMarker> getReadByMarkers() {
 896        return ImmutableSet.copyOf(this.readByMarkers);
 897    }
 898
 899    public Set<Jid> getReadyByTrue() {
 900        return ImmutableSet.copyOf(
 901                Collections2.transform(
 902                        Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
 903                        ReadByMarker::getRealJid));
 904    }
 905
 906    public void setInReplyTo(final Message m) {
 907        mInReplyTo = m;
 908    }
 909
 910    public Message getInReplyTo() {
 911        return mInReplyTo;
 912    }
 913
 914    boolean similar(Message message) {
 915        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 916            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 917        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 918            return true;
 919        } else if (this.body == null || this.counterpart == null) {
 920            return false;
 921        } else {
 922            String body, otherBody;
 923            if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
 924                body = getFileParams().url;
 925                otherBody = message.body == null ? null : message.body.trim();
 926            } else {
 927                body = this.body;
 928                otherBody = message.body;
 929            }
 930            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 931            if (message.getRemoteMsgId() != null) {
 932                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 933                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 934                    return true;
 935                }
 936                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 937                        && matchingCounterpart
 938                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 939            } else {
 940                return this.remoteMsgId == null
 941                        && matchingCounterpart
 942                        && body.equals(otherBody)
 943                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 944            }
 945        }
 946    }
 947
 948    public Message next() {
 949        if (this.conversation instanceof Conversation) {
 950            final Conversation conversation = (Conversation) this.conversation;
 951            synchronized (conversation.messages) {
 952                if (this.mNextMessage == null) {
 953                    int index = conversation.messages.indexOf(this);
 954                    if (index < 0 || index >= conversation.messages.size() - 1) {
 955                        this.mNextMessage = null;
 956                    } else {
 957                        this.mNextMessage = conversation.messages.get(index + 1);
 958                    }
 959                }
 960                return this.mNextMessage;
 961            }
 962        } else {
 963            throw new AssertionError("Calling next should be disabled for stubs");
 964        }
 965    }
 966
 967    public Message prev() {
 968        if (this.conversation instanceof Conversation) {
 969            final Conversation conversation = (Conversation) this.conversation;
 970            synchronized (conversation.messages) {
 971                if (this.mPreviousMessage == null) {
 972                    int index = conversation.messages.indexOf(this);
 973                    if (index <= 0 || index > conversation.messages.size()) {
 974                        this.mPreviousMessage = null;
 975                    } else {
 976                        this.mPreviousMessage = conversation.messages.get(index - 1);
 977                    }
 978                }
 979            }
 980            return this.mPreviousMessage;
 981        } else {
 982            throw new AssertionError("Calling prev should be disabled for stubs");
 983        }
 984    }
 985
 986    public boolean isLastCorrectableMessage() {
 987        Message next = next();
 988        while (next != null) {
 989            if (next.isEditable()) {
 990                return false;
 991            }
 992            next = next.next();
 993        }
 994        return isEditable();
 995    }
 996
 997    public boolean isEditable() {
 998        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 999    }
1000
1001    public boolean mergeable(final Message message) {
1002        return false; // Merrgine messages messes up reply, so disable for now
1003    }
1004
1005    private static boolean isStatusMergeable(int a, int b) {
1006        return a == b || (
1007                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
1008                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
1009                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
1010                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
1011                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
1012        );
1013    }
1014
1015    private static boolean isEncryptionMergeable(final int a, final int b) {
1016        return a == b
1017                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
1018                        .contains(a);
1019    }
1020
1021    public void setCounterparts(List<MucOptions.User> counterparts) {
1022        this.counterparts = counterparts;
1023    }
1024
1025    public List<MucOptions.User> getCounterparts() {
1026        return this.counterparts;
1027    }
1028
1029    @Override
1030    public int getAvatarBackgroundColor() {
1031        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
1032            return Color.TRANSPARENT;
1033        } else {
1034            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
1035        }
1036    }
1037
1038    @Override
1039    public String getAvatarName() {
1040        return UIHelper.getMessageDisplayName(this);
1041    }
1042
1043    public boolean isOOb() {
1044        return oob || getFileParams().url != null;
1045    }
1046
1047    public static class MergeSeparator {
1048    }
1049
1050    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1051        SpannableStringBuilder spannableBody;
1052        final Element html = getHtml();
1053        if (html == null || Build.VERSION.SDK_INT < 24) {
1054            spannableBody = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody(getInReplyTo() != null)).trim());
1055            spannableBody.setSpan(PLAIN_TEXT_SPAN, 0, spannableBody.length(), 0); // Let adapter know it can do more formatting
1056        } else {
1057            boolean[] anyfallbackimg = new boolean[]{ false };
1058
1059            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
1060                MessageUtils.filterLtrRtl(html.toString()).trim(),
1061                Html.FROM_HTML_MODE_COMPACT,
1062                (source) -> {
1063                   try {
1064                       if (thumbnailer == null || source == null) {
1065                           anyfallbackimg[0] = true;
1066                           return fallbackImg;
1067                       }
1068                       Cid cid = BobTransfer.cid(new URI(source));
1069                       if (cid == null) {
1070                           anyfallbackimg[0] = true;
1071                           return fallbackImg;
1072                       }
1073                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
1074                       if (thumbnail == null) {
1075                           anyfallbackimg[0] = true;
1076                           return fallbackImg;
1077                       }
1078                       return thumbnail;
1079                   } catch (final URISyntaxException e) {
1080                       anyfallbackimg[0] = true;
1081                       return fallbackImg;
1082                   }
1083                },
1084                (opening, tag, output, xmlReader) -> {}
1085            ));
1086
1087            // Make images clickable and long-clickable with BetterLinkMovementMethod
1088            ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1089            for (ImageSpan span : imageSpans) {
1090                final int start = spannable.getSpanStart(span);
1091                final int end = spannable.getSpanEnd(span);
1092
1093                ClickableSpan click_span = new ClickableSpan() {
1094                    @Override
1095                    public void onClick(View widget) { }
1096                };
1097
1098                spannable.removeSpan(span);
1099                spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1100                spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1101            }
1102
1103            // https://stackoverflow.com/a/10187511/8611
1104            int i = spannable.length();
1105            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
1106            if (anyfallbackimg[0]) return (SpannableStringBuilder) spannable.subSequence(0, i+1);
1107            spannableBody = (SpannableStringBuilder) spannable.subSequence(0, i+1);
1108        }
1109
1110        if (getInReplyTo() != null) {
1111            final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1112            if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1113                quote.insert(0, "🖼️");
1114                final var cid = getInReplyTo().getFileParams().getCids().get(0);
1115                Drawable thumbnail = thumbnailer == null ? null : thumbnailer.getThumbnail(cid);
1116                if (thumbnail == null) thumbnail = fallbackImg;
1117                if (thumbnail != null) {
1118                    quote.setSpan(new InlineImageSpan(thumbnail, cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1119                }
1120            }
1121            quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1122            spannableBody.insert(0, "\n");
1123            spannableBody.insert(0, quote);
1124        }
1125
1126        return spannableBody;
1127    }
1128
1129    public SpannableStringBuilder getMergedBody() {
1130        return getMergedBody(null, null);
1131    }
1132
1133    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1134        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1135        Message current = this;
1136        while (current.mergeable(current.next())) {
1137            current = current.next();
1138            if (current == null || current.getModerated() != null) {
1139                break;
1140            }
1141            body.append("\n\n");
1142            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1143                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1144            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1145        }
1146        return body;
1147    }
1148
1149    public boolean hasMeCommand() {
1150        return this.body.trim().startsWith(ME_COMMAND);
1151    }
1152
1153    public int getMergedStatus() {
1154        int status = this.status;
1155        Message current = this;
1156        while (current.mergeable(current.next())) {
1157            current = current.next();
1158            if (current == null) {
1159                break;
1160            }
1161            status = current.status;
1162        }
1163        return status;
1164    }
1165
1166    public long getMergedTimeSent() {
1167        long time = this.timeSent;
1168        Message current = this;
1169        while (current.mergeable(current.next())) {
1170            current = current.next();
1171            if (current == null) {
1172                break;
1173            }
1174            time = current.timeSent;
1175        }
1176        return time;
1177    }
1178
1179    public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1180        Message prev = this.prev();
1181        if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1182        if (getOccupantId() != null && xmppConnectionService != null) {
1183            final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1184            if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1185        }
1186        return prev != null && prev.mergeable(this);
1187    }
1188
1189    public boolean trusted() {
1190        Contact contact = this.getContact();
1191        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1192    }
1193
1194    public boolean fixCounterpart() {
1195        final Presences presences = conversation.getContact().getPresences();
1196        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1197            return true;
1198        } else if (presences.size() >= 1) {
1199            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1200            return true;
1201        } else {
1202            counterpart = null;
1203            return false;
1204        }
1205    }
1206
1207    public void setUuid(String uuid) {
1208        this.uuid = uuid;
1209    }
1210
1211    public String getEditedId() {
1212        if (edits.size() > 0) {
1213            return edits.get(edits.size() - 1).getEditedId();
1214        } else {
1215            throw new IllegalStateException("Attempting to store unedited message");
1216        }
1217    }
1218
1219    public String getEditedIdWireFormat() {
1220        if (edits.size() > 0) {
1221            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1222        } else {
1223            throw new IllegalStateException("Attempting to store unedited message");
1224        }
1225    }
1226
1227    public List<URI> getLinks() {
1228        SpannableStringBuilder text = new SpannableStringBuilder(
1229            getBody().replaceAll("^>.*", "") // Remove quotes
1230        );
1231        return MyLinkify.extractLinks(text).stream().map((url) -> {
1232            try {
1233                return new URI(url);
1234            } catch (final URISyntaxException e) {
1235                return null;
1236            }
1237        }).filter(x -> x != null).collect(Collectors.toList());
1238    }
1239
1240    public URI getOob() {
1241        final String url = getFileParams().url;
1242        try {
1243            return url == null ? null : new URI(url);
1244        } catch (final URISyntaxException e) {
1245            return null;
1246        }
1247    }
1248
1249    public void clearPayloads() {
1250        this.payloads.clear();
1251    }
1252
1253    public void addPayload(Element el) {
1254        if (el == null) return;
1255
1256        this.payloads.add(el);
1257    }
1258
1259    public List<Element> getPayloads() {
1260       return new ArrayList<>(this.payloads);
1261    }
1262
1263    public List<Element> getFallbacks(String... includeFor) {
1264        List<Element> fallbacks = new ArrayList<>();
1265
1266        if (this.payloads == null) return fallbacks;
1267
1268        for (Element el : this.payloads) {
1269            if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1270                final String fallbackFor = el.getAttribute("for");
1271                if (fallbackFor == null) continue;
1272                for (String includeOne : includeFor) {
1273                    if (fallbackFor.equals(includeOne)) {
1274                        fallbacks.add(el);
1275                        break;
1276                    }
1277                }
1278            }
1279        }
1280
1281        return fallbacks;
1282    }
1283
1284    public Element getHtml() {
1285        return getHtml(false);
1286    }
1287
1288    public Element getHtml(boolean root) {
1289        if (this.payloads == null) return null;
1290
1291        for (Element el : this.payloads) {
1292            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1293                return root ? el : el.getChildren().get(0);
1294            }
1295        }
1296
1297        return null;
1298   }
1299
1300    public List<Element> getCommands() {
1301        if (this.payloads == null) return null;
1302
1303        for (Element el : this.payloads) {
1304            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1305                return el.getChildren();
1306            }
1307        }
1308
1309        return null;
1310    }
1311
1312    public String getMimeType() {
1313        String extension;
1314        if (relativeFilePath != null) {
1315            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1316        } else {
1317            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1318            if (url == null) {
1319                return null;
1320            }
1321            extension = MimeUtils.extractRelevantExtension(url);
1322        }
1323        return MimeUtils.guessMimeTypeFromExtension(extension);
1324    }
1325
1326    public synchronized boolean treatAsDownloadable() {
1327        if (treatAsDownloadable == null) {
1328            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1329        }
1330        return treatAsDownloadable;
1331    }
1332
1333    public synchronized boolean hasCustomEmoji() {
1334        if (getHtml() != null) {
1335            SpannableStringBuilder spannable = getSpannableBody(null, null);
1336            ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1337            return imageSpans.length > 0;
1338        }
1339
1340        return false;
1341    }
1342
1343    public synchronized boolean bodyIsOnlyEmojis() {
1344        if (isEmojisOnly == null) {
1345            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1346            if (isEmojisOnly) return true;
1347
1348            if (getHtml() != null) {
1349                SpannableStringBuilder spannable = getSpannableBody(null, null);
1350                ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1351                for (ImageSpan span : imageSpans) {
1352                    final int start = spannable.getSpanStart(span);
1353                    final int end = spannable.getSpanEnd(span);
1354                    spannable.delete(start, end);
1355                }
1356                final String after = spannable.toString().replaceAll("\\s", "");
1357                isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1358            }
1359        }
1360        return isEmojisOnly;
1361    }
1362
1363    public synchronized boolean isGeoUri() {
1364        if (isGeoUri == null) {
1365            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1366        }
1367        return isGeoUri;
1368    }
1369
1370    protected List<Element> getSims() {
1371        return payloads.stream().filter(el ->
1372            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1373            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1374        ).collect(Collectors.toList());
1375    }
1376
1377    public synchronized void resetFileParams() {
1378        this.fileParams = null;
1379    }
1380
1381    public synchronized void setFileParams(FileParams fileParams) {
1382        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1383            fileParams.sims = this.fileParams.sims;
1384        }
1385        this.fileParams = fileParams;
1386        if (fileParams != null && getSims().isEmpty()) {
1387            addPayload(fileParams.toSims());
1388        }
1389    }
1390
1391    public synchronized FileParams getFileParams() {
1392        if (fileParams == null) {
1393            List<Element> sims = getSims();
1394            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1395            if (this.transferable != null) {
1396                fileParams.size = this.transferable.getFileSize();
1397            }
1398        }
1399
1400        return fileParams;
1401    }
1402
1403    private static int parseInt(String value) {
1404        try {
1405            return Integer.parseInt(value);
1406        } catch (NumberFormatException e) {
1407            return 0;
1408        }
1409    }
1410
1411    public void untie() {
1412        this.mNextMessage = null;
1413        this.mPreviousMessage = null;
1414    }
1415
1416    public boolean isPrivateMessage() {
1417        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1418    }
1419
1420    public boolean isFileOrImage() {
1421        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1422    }
1423
1424
1425    public boolean isTypeText() {
1426        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1427    }
1428
1429    public boolean hasFileOnRemoteHost() {
1430        return isFileOrImage() && getFileParams().url != null;
1431    }
1432
1433    public boolean needsUploading() {
1434        return isFileOrImage() && getFileParams().url == null;
1435    }
1436
1437    public static class FileParams {
1438        public String url;
1439        public Long size = null;
1440        public int width = 0;
1441        public int height = 0;
1442        public int runtime = 0;
1443        public Element sims = null;
1444
1445        public FileParams() { }
1446
1447        public FileParams(Element el) {
1448            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1449                this.url = el.findChildContent("url", Namespace.OOB);
1450            }
1451            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1452                sims = el;
1453                final String refUri = el.getAttribute("uri");
1454                if (refUri != null) url = refUri;
1455                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1456                if (mediaSharing != null) {
1457                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1458                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1459                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1460                    if (file != null) {
1461                        try {
1462                            String sizeS = file.findChildContent("size", file.getNamespace());
1463                            if (sizeS != null) size = new Long(sizeS);
1464                            String widthS = file.findChildContent("width", "https://schema.org/");
1465                            if (widthS != null) width = parseInt(widthS);
1466                            String heightS = file.findChildContent("height", "https://schema.org/");
1467                            if (heightS != null) height = parseInt(heightS);
1468                            String durationS = file.findChildContent("duration", "https://schema.org/");
1469                            if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1470                        } catch (final NumberFormatException e) {
1471                            Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1472                        }
1473                    }
1474
1475                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1476                    if (sources != null) {
1477                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1478                        if (ref != null) url = ref.getAttribute("uri");
1479                    }
1480                }
1481            }
1482        }
1483
1484        public FileParams(String ser) {
1485            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1486            switch (parts.length) {
1487                case 1:
1488                    try {
1489                        this.size = Long.parseLong(parts[0]);
1490                    } catch (final NumberFormatException e) {
1491                        this.url = URL.tryParse(parts[0]);
1492                    }
1493                    break;
1494                case 5:
1495                    this.runtime = parseInt(parts[4]);
1496                case 4:
1497                    this.width = parseInt(parts[2]);
1498                    this.height = parseInt(parts[3]);
1499                case 2:
1500                    this.url = URL.tryParse(parts[0]);
1501                    this.size = Longs.tryParse(parts[1]);
1502                    break;
1503                case 3:
1504                    this.size = Longs.tryParse(parts[0]);
1505                    this.width = parseInt(parts[1]);
1506                    this.height = parseInt(parts[2]);
1507                    break;
1508            }
1509        }
1510
1511        public boolean isEmpty() {
1512            return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1513        }
1514
1515        public long getSize() {
1516            return size == null ? 0 : size;
1517        }
1518
1519        public String getName() {
1520            Element file = getFileElement();
1521            if (file == null) return null;
1522
1523            return file.findChildContent("name", file.getNamespace());
1524        }
1525
1526        public void setName(final String name) {
1527            if (sims == null) toSims();
1528            Element file = getFileElement();
1529
1530            for (Element child : file.getChildren()) {
1531                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1532                    file.removeChild(child);
1533                }
1534            }
1535
1536            if (name != null) {
1537                file.addChild("name", file.getNamespace()).setContent(name);
1538            }
1539        }
1540
1541        public String getMediaType() {
1542            Element file = getFileElement();
1543            if (file == null) return null;
1544
1545            return file.findChildContent("media-type", file.getNamespace());
1546        }
1547
1548        public void setMediaType(final String mime) {
1549            if (sims == null) toSims();
1550            Element file = getFileElement();
1551
1552            for (Element child : file.getChildren()) {
1553                if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1554                    file.removeChild(child);
1555                }
1556            }
1557
1558            if (mime != null) {
1559                file.addChild("media-type", file.getNamespace()).setContent(mime);
1560            }
1561        }
1562
1563        public Element toSims() {
1564            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1565            sims.setAttribute("type", "data");
1566            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1567            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1568
1569            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1570            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1571            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1572            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1573
1574            file.removeChild(file.findChild("size", file.getNamespace()));
1575            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1576
1577            file.removeChild(file.findChild("width", "https://schema.org/"));
1578            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1579
1580            file.removeChild(file.findChild("height", "https://schema.org/"));
1581            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1582
1583            file.removeChild(file.findChild("duration", "https://schema.org/"));
1584            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1585
1586            if (url != null) {
1587                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1588                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1589
1590                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1591                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1592                source.setAttribute("type", "data");
1593                source.setAttribute("uri", url);
1594            }
1595
1596            return sims;
1597        }
1598
1599        protected Element getFileElement() {
1600            Element file = null;
1601            if (sims == null) return file;
1602
1603            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1604            if (mediaSharing == null) return file;
1605            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1606            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1607            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1608            return file;
1609        }
1610
1611        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1612            if (sims == null) toSims();
1613            Element file = getFileElement();
1614
1615            for (Element child : file.getChildren()) {
1616                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1617                    file.removeChild(child);
1618                }
1619            }
1620
1621            for (Cid cid : cids) {
1622                file.addChild("hash", "urn:xmpp:hashes:2")
1623                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1624                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1625            }
1626        }
1627
1628        public List<Cid> getCids() {
1629            List<Cid> cids = new ArrayList<>();
1630            Element file = getFileElement();
1631            if (file == null) return cids;
1632
1633            for (Element child : file.getChildren()) {
1634                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1635                    try {
1636                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1637                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1638                }
1639            }
1640
1641            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1642
1643            return cids;
1644        }
1645
1646        public void addThumbnail(int width, int height, String mimeType, String uri) {
1647            for (Element thumb : getThumbnails()) {
1648                if (uri.equals(thumb.getAttribute("uri"))) return;
1649            }
1650
1651            if (sims == null) toSims();
1652            Element file = getFileElement();
1653            file.addChild(
1654                new Element("thumbnail", "urn:xmpp:thumbs:1")
1655                    .setAttribute("width", Integer.toString(width))
1656                    .setAttribute("height", Integer.toString(height))
1657                    .setAttribute("type", mimeType)
1658                    .setAttribute("uri", uri)
1659            );
1660        }
1661
1662        public List<Element> getThumbnails() {
1663            List<Element> thumbs = new ArrayList<>();
1664            Element file = getFileElement();
1665            if (file == null) return thumbs;
1666
1667            for (Element child : file.getChildren()) {
1668                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1669                    thumbs.add(child);
1670                }
1671            }
1672
1673            return thumbs;
1674        }
1675
1676        public String toString() {
1677            final StringBuilder builder = new StringBuilder();
1678            if (url != null) builder.append(url);
1679            if (size != null) builder.append('|').append(size.toString());
1680            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1681            if (height > 0 || runtime > 0) builder.append('|').append(height);
1682            if (runtime > 0) builder.append('|').append(runtime);
1683            return builder.toString();
1684        }
1685
1686        public boolean equals(Object o) {
1687            if (!(o instanceof FileParams)) return false;
1688            if (url == null) return false;
1689
1690            return url.equals(((FileParams) o).url);
1691        }
1692
1693        public int hashCode() {
1694            return url == null ? super.hashCode() : url.hashCode();
1695        }
1696    }
1697
1698    public void setFingerprint(String fingerprint) {
1699        this.axolotlFingerprint = fingerprint;
1700    }
1701
1702    public String getFingerprint() {
1703        return axolotlFingerprint;
1704    }
1705
1706    public boolean isTrusted() {
1707        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1708        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1709        return s != null && s.isTrusted();
1710    }
1711
1712    private int getPreviousEncryption() {
1713        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1714            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1715                continue;
1716            }
1717            return iterator.getEncryption();
1718        }
1719        return ENCRYPTION_NONE;
1720    }
1721
1722    private int getNextEncryption() {
1723        if (this.conversation instanceof Conversation) {
1724            Conversation conversation = (Conversation) this.conversation;
1725            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1726                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1727                    continue;
1728                }
1729                return iterator.getEncryption();
1730            }
1731            return conversation.getNextEncryption();
1732        } else {
1733            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1734        }
1735    }
1736
1737    public boolean isValidInSession() {
1738        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1739        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1740
1741        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1742                || futureEncryption == ENCRYPTION_NONE
1743                || pastEncryption != futureEncryption;
1744
1745        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1746    }
1747
1748    private static int getCleanedEncryption(int encryption) {
1749        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1750            return ENCRYPTION_PGP;
1751        }
1752        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1753            return ENCRYPTION_AXOLOTL;
1754        }
1755        return encryption;
1756    }
1757
1758    public static boolean configurePrivateMessage(final Message message) {
1759        return configurePrivateMessage(message, false);
1760    }
1761
1762    public static boolean configurePrivateFileMessage(final Message message) {
1763        return configurePrivateMessage(message, true);
1764    }
1765
1766    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1767        final Conversation conversation;
1768        if (message.conversation instanceof Conversation) {
1769            conversation = (Conversation) message.conversation;
1770        } else {
1771            return false;
1772        }
1773        if (conversation.getMode() == Conversation.MODE_MULTI) {
1774            final Jid nextCounterpart = conversation.getNextCounterpart();
1775            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1776        }
1777        return false;
1778    }
1779
1780    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1781        final Conversation conversation;
1782        if (message.conversation instanceof Conversation) {
1783            conversation = (Conversation) message.conversation;
1784        } else {
1785            return false;
1786        }
1787        return configurePrivateMessage(conversation, message, counterpart, false);
1788    }
1789
1790    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1791        if (counterpart == null) {
1792            return false;
1793        }
1794        message.setCounterpart(counterpart);
1795        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1796        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1797        return true;
1798    }
1799
1800    public static class PlainTextSpan {}
1801}