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            // Don't show quote if it's the message right before us
1112            if (prev() != null && prev().getUuid().equals(getInReplyTo().getUuid())) return spannableBody;
1113
1114            final var quote = getInReplyTo().getSpannableBody(thumbnailer, fallbackImg);
1115            if ((getInReplyTo().isFileOrImage() || getInReplyTo().isOOb()) && getInReplyTo().getFileParams() != null) {
1116                quote.insert(0, "🖼️");
1117                final var cid = getInReplyTo().getFileParams().getCids().get(0);
1118                Drawable thumbnail = thumbnailer == null ? null : thumbnailer.getThumbnail(cid);
1119                if (thumbnail == null) thumbnail = fallbackImg;
1120                if (thumbnail != null) {
1121                    quote.setSpan(new InlineImageSpan(thumbnail, cid.toString()), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1122                }
1123            }
1124            quote.setSpan(new android.text.style.QuoteSpan(), 0, quote.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1125            spannableBody.insert(0, "\n");
1126            spannableBody.insert(0, quote);
1127        }
1128
1129        return spannableBody;
1130    }
1131
1132    public SpannableStringBuilder getMergedBody() {
1133        return getMergedBody(null, null);
1134    }
1135
1136    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
1137        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
1138        Message current = this;
1139        while (current.mergeable(current.next())) {
1140            current = current.next();
1141            if (current == null || current.getModerated() != null) {
1142                break;
1143            }
1144            body.append("\n\n");
1145            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1146                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1147            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1148        }
1149        return body;
1150    }
1151
1152    public boolean hasMeCommand() {
1153        return this.body.trim().startsWith(ME_COMMAND);
1154    }
1155
1156    public int getMergedStatus() {
1157        int status = this.status;
1158        Message current = this;
1159        while (current.mergeable(current.next())) {
1160            current = current.next();
1161            if (current == null) {
1162                break;
1163            }
1164            status = current.status;
1165        }
1166        return status;
1167    }
1168
1169    public long getMergedTimeSent() {
1170        long time = this.timeSent;
1171        Message current = this;
1172        while (current.mergeable(current.next())) {
1173            current = current.next();
1174            if (current == null) {
1175                break;
1176            }
1177            time = current.timeSent;
1178        }
1179        return time;
1180    }
1181
1182    public boolean wasMergedIntoPrevious(XmppConnectionService xmppConnectionService) {
1183        Message prev = this.prev();
1184        if (prev != null && getModerated() != null && prev.getModerated() != null) return true;
1185        if (getOccupantId() != null && xmppConnectionService != null) {
1186            final boolean muted = getStatus() == Message.STATUS_RECEIVED && conversation.getMode() == Conversation.MODE_MULTI && xmppConnectionService.isMucUserMuted(new MucOptions.User(null, conversation.getJid(), getOccupantId(), null, null));
1187            if (prev != null && muted && getOccupantId().equals(prev.getOccupantId())) return true;
1188        }
1189        return prev != null && prev.mergeable(this);
1190    }
1191
1192    public boolean trusted() {
1193        Contact contact = this.getContact();
1194        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1195    }
1196
1197    public boolean fixCounterpart() {
1198        final Presences presences = conversation.getContact().getPresences();
1199        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1200            return true;
1201        } else if (presences.size() >= 1) {
1202            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1203            return true;
1204        } else {
1205            counterpart = null;
1206            return false;
1207        }
1208    }
1209
1210    public void setUuid(String uuid) {
1211        this.uuid = uuid;
1212    }
1213
1214    public String getEditedId() {
1215        if (edits.size() > 0) {
1216            return edits.get(edits.size() - 1).getEditedId();
1217        } else {
1218            throw new IllegalStateException("Attempting to store unedited message");
1219        }
1220    }
1221
1222    public String getEditedIdWireFormat() {
1223        if (edits.size() > 0) {
1224            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1225        } else {
1226            throw new IllegalStateException("Attempting to store unedited message");
1227        }
1228    }
1229
1230    public List<URI> getLinks() {
1231        SpannableStringBuilder text = new SpannableStringBuilder(
1232            getBody().replaceAll("^>.*", "") // Remove quotes
1233        );
1234        return MyLinkify.extractLinks(text).stream().map((url) -> {
1235            try {
1236                return new URI(url);
1237            } catch (final URISyntaxException e) {
1238                return null;
1239            }
1240        }).filter(x -> x != null).collect(Collectors.toList());
1241    }
1242
1243    public URI getOob() {
1244        final String url = getFileParams().url;
1245        try {
1246            return url == null ? null : new URI(url);
1247        } catch (final URISyntaxException e) {
1248            return null;
1249        }
1250    }
1251
1252    public void clearPayloads() {
1253        this.payloads.clear();
1254    }
1255
1256    public void addPayload(Element el) {
1257        if (el == null) return;
1258
1259        this.payloads.add(el);
1260    }
1261
1262    public List<Element> getPayloads() {
1263       return new ArrayList<>(this.payloads);
1264    }
1265
1266    public List<Element> getFallbacks(String... includeFor) {
1267        List<Element> fallbacks = new ArrayList<>();
1268
1269        if (this.payloads == null) return fallbacks;
1270
1271        for (Element el : this.payloads) {
1272            if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1273                final String fallbackFor = el.getAttribute("for");
1274                if (fallbackFor == null) continue;
1275                for (String includeOne : includeFor) {
1276                    if (fallbackFor.equals(includeOne)) {
1277                        fallbacks.add(el);
1278                        break;
1279                    }
1280                }
1281            }
1282        }
1283
1284        return fallbacks;
1285    }
1286
1287    public Element getHtml() {
1288        return getHtml(false);
1289    }
1290
1291    public Element getHtml(boolean root) {
1292        if (this.payloads == null) return null;
1293
1294        for (Element el : this.payloads) {
1295            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1296                return root ? el : el.getChildren().get(0);
1297            }
1298        }
1299
1300        return null;
1301   }
1302
1303    public List<Element> getCommands() {
1304        if (this.payloads == null) return null;
1305
1306        for (Element el : this.payloads) {
1307            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1308                return el.getChildren();
1309            }
1310        }
1311
1312        return null;
1313    }
1314
1315    public String getMimeType() {
1316        String extension;
1317        if (relativeFilePath != null) {
1318            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1319        } else {
1320            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1321            if (url == null) {
1322                return null;
1323            }
1324            extension = MimeUtils.extractRelevantExtension(url);
1325        }
1326        return MimeUtils.guessMimeTypeFromExtension(extension);
1327    }
1328
1329    public synchronized boolean treatAsDownloadable() {
1330        if (treatAsDownloadable == null) {
1331            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1332        }
1333        return treatAsDownloadable;
1334    }
1335
1336    public synchronized boolean hasCustomEmoji() {
1337        if (getHtml() != null) {
1338            SpannableStringBuilder spannable = getSpannableBody(null, null);
1339            ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1340            return imageSpans.length > 0;
1341        }
1342
1343        return false;
1344    }
1345
1346    public synchronized boolean bodyIsOnlyEmojis() {
1347        if (isEmojisOnly == null) {
1348            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1349            if (isEmojisOnly) return true;
1350
1351            if (getHtml() != null) {
1352                SpannableStringBuilder spannable = getSpannableBody(null, null);
1353                ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
1354                for (ImageSpan span : imageSpans) {
1355                    final int start = spannable.getSpanStart(span);
1356                    final int end = spannable.getSpanEnd(span);
1357                    spannable.delete(start, end);
1358                }
1359                final String after = spannable.toString().replaceAll("\\s", "");
1360                isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
1361            }
1362        }
1363        return isEmojisOnly;
1364    }
1365
1366    public synchronized boolean isGeoUri() {
1367        if (isGeoUri == null) {
1368            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1369        }
1370        return isGeoUri;
1371    }
1372
1373    protected List<Element> getSims() {
1374        return payloads.stream().filter(el ->
1375            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1376            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1377        ).collect(Collectors.toList());
1378    }
1379
1380    public synchronized void resetFileParams() {
1381        this.fileParams = null;
1382    }
1383
1384    public synchronized void setFileParams(FileParams fileParams) {
1385        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1386            fileParams.sims = this.fileParams.sims;
1387        }
1388        this.fileParams = fileParams;
1389        if (fileParams != null && getSims().isEmpty()) {
1390            addPayload(fileParams.toSims());
1391        }
1392    }
1393
1394    public synchronized FileParams getFileParams() {
1395        if (fileParams == null) {
1396            List<Element> sims = getSims();
1397            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1398            if (this.transferable != null) {
1399                fileParams.size = this.transferable.getFileSize();
1400            }
1401        }
1402
1403        return fileParams;
1404    }
1405
1406    private static int parseInt(String value) {
1407        try {
1408            return Integer.parseInt(value);
1409        } catch (NumberFormatException e) {
1410            return 0;
1411        }
1412    }
1413
1414    public void untie() {
1415        this.mNextMessage = null;
1416        this.mPreviousMessage = null;
1417    }
1418
1419    public boolean isPrivateMessage() {
1420        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1421    }
1422
1423    public boolean isFileOrImage() {
1424        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1425    }
1426
1427
1428    public boolean isTypeText() {
1429        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1430    }
1431
1432    public boolean hasFileOnRemoteHost() {
1433        return isFileOrImage() && getFileParams().url != null;
1434    }
1435
1436    public boolean needsUploading() {
1437        return isFileOrImage() && getFileParams().url == null;
1438    }
1439
1440    public static class FileParams {
1441        public String url;
1442        public Long size = null;
1443        public int width = 0;
1444        public int height = 0;
1445        public int runtime = 0;
1446        public Element sims = null;
1447
1448        public FileParams() { }
1449
1450        public FileParams(Element el) {
1451            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1452                this.url = el.findChildContent("url", Namespace.OOB);
1453            }
1454            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1455                sims = el;
1456                final String refUri = el.getAttribute("uri");
1457                if (refUri != null) url = refUri;
1458                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1459                if (mediaSharing != null) {
1460                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1461                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1462                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1463                    if (file != null) {
1464                        try {
1465                            String sizeS = file.findChildContent("size", file.getNamespace());
1466                            if (sizeS != null) size = new Long(sizeS);
1467                            String widthS = file.findChildContent("width", "https://schema.org/");
1468                            if (widthS != null) width = parseInt(widthS);
1469                            String heightS = file.findChildContent("height", "https://schema.org/");
1470                            if (heightS != null) height = parseInt(heightS);
1471                            String durationS = file.findChildContent("duration", "https://schema.org/");
1472                            if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1473                        } catch (final NumberFormatException e) {
1474                            Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1475                        }
1476                    }
1477
1478                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1479                    if (sources != null) {
1480                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1481                        if (ref != null) url = ref.getAttribute("uri");
1482                    }
1483                }
1484            }
1485        }
1486
1487        public FileParams(String ser) {
1488            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1489            switch (parts.length) {
1490                case 1:
1491                    try {
1492                        this.size = Long.parseLong(parts[0]);
1493                    } catch (final NumberFormatException e) {
1494                        this.url = URL.tryParse(parts[0]);
1495                    }
1496                    break;
1497                case 5:
1498                    this.runtime = parseInt(parts[4]);
1499                case 4:
1500                    this.width = parseInt(parts[2]);
1501                    this.height = parseInt(parts[3]);
1502                case 2:
1503                    this.url = URL.tryParse(parts[0]);
1504                    this.size = Longs.tryParse(parts[1]);
1505                    break;
1506                case 3:
1507                    this.size = Longs.tryParse(parts[0]);
1508                    this.width = parseInt(parts[1]);
1509                    this.height = parseInt(parts[2]);
1510                    break;
1511            }
1512        }
1513
1514        public boolean isEmpty() {
1515            return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1516        }
1517
1518        public long getSize() {
1519            return size == null ? 0 : size;
1520        }
1521
1522        public String getName() {
1523            Element file = getFileElement();
1524            if (file == null) return null;
1525
1526            return file.findChildContent("name", file.getNamespace());
1527        }
1528
1529        public void setName(final String name) {
1530            if (sims == null) toSims();
1531            Element file = getFileElement();
1532
1533            for (Element child : file.getChildren()) {
1534                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1535                    file.removeChild(child);
1536                }
1537            }
1538
1539            if (name != null) {
1540                file.addChild("name", file.getNamespace()).setContent(name);
1541            }
1542        }
1543
1544        public String getMediaType() {
1545            Element file = getFileElement();
1546            if (file == null) return null;
1547
1548            return file.findChildContent("media-type", file.getNamespace());
1549        }
1550
1551        public void setMediaType(final String mime) {
1552            if (sims == null) toSims();
1553            Element file = getFileElement();
1554
1555            for (Element child : file.getChildren()) {
1556                if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1557                    file.removeChild(child);
1558                }
1559            }
1560
1561            if (mime != null) {
1562                file.addChild("media-type", file.getNamespace()).setContent(mime);
1563            }
1564        }
1565
1566        public Element toSims() {
1567            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1568            sims.setAttribute("type", "data");
1569            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1570            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1571
1572            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1573            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1574            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1575            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1576
1577            file.removeChild(file.findChild("size", file.getNamespace()));
1578            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1579
1580            file.removeChild(file.findChild("width", "https://schema.org/"));
1581            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1582
1583            file.removeChild(file.findChild("height", "https://schema.org/"));
1584            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1585
1586            file.removeChild(file.findChild("duration", "https://schema.org/"));
1587            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1588
1589            if (url != null) {
1590                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1591                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1592
1593                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1594                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1595                source.setAttribute("type", "data");
1596                source.setAttribute("uri", url);
1597            }
1598
1599            return sims;
1600        }
1601
1602        protected Element getFileElement() {
1603            Element file = null;
1604            if (sims == null) return file;
1605
1606            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1607            if (mediaSharing == null) return file;
1608            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1609            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1610            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1611            return file;
1612        }
1613
1614        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1615            if (sims == null) toSims();
1616            Element file = getFileElement();
1617
1618            for (Element child : file.getChildren()) {
1619                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1620                    file.removeChild(child);
1621                }
1622            }
1623
1624            for (Cid cid : cids) {
1625                file.addChild("hash", "urn:xmpp:hashes:2")
1626                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1627                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1628            }
1629        }
1630
1631        public List<Cid> getCids() {
1632            List<Cid> cids = new ArrayList<>();
1633            Element file = getFileElement();
1634            if (file == null) return cids;
1635
1636            for (Element child : file.getChildren()) {
1637                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1638                    try {
1639                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1640                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1641                }
1642            }
1643
1644            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1645
1646            return cids;
1647        }
1648
1649        public void addThumbnail(int width, int height, String mimeType, String uri) {
1650            for (Element thumb : getThumbnails()) {
1651                if (uri.equals(thumb.getAttribute("uri"))) return;
1652            }
1653
1654            if (sims == null) toSims();
1655            Element file = getFileElement();
1656            file.addChild(
1657                new Element("thumbnail", "urn:xmpp:thumbs:1")
1658                    .setAttribute("width", Integer.toString(width))
1659                    .setAttribute("height", Integer.toString(height))
1660                    .setAttribute("type", mimeType)
1661                    .setAttribute("uri", uri)
1662            );
1663        }
1664
1665        public List<Element> getThumbnails() {
1666            List<Element> thumbs = new ArrayList<>();
1667            Element file = getFileElement();
1668            if (file == null) return thumbs;
1669
1670            for (Element child : file.getChildren()) {
1671                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1672                    thumbs.add(child);
1673                }
1674            }
1675
1676            return thumbs;
1677        }
1678
1679        public String toString() {
1680            final StringBuilder builder = new StringBuilder();
1681            if (url != null) builder.append(url);
1682            if (size != null) builder.append('|').append(size.toString());
1683            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1684            if (height > 0 || runtime > 0) builder.append('|').append(height);
1685            if (runtime > 0) builder.append('|').append(runtime);
1686            return builder.toString();
1687        }
1688
1689        public boolean equals(Object o) {
1690            if (!(o instanceof FileParams)) return false;
1691            if (url == null) return false;
1692
1693            return url.equals(((FileParams) o).url);
1694        }
1695
1696        public int hashCode() {
1697            return url == null ? super.hashCode() : url.hashCode();
1698        }
1699    }
1700
1701    public void setFingerprint(String fingerprint) {
1702        this.axolotlFingerprint = fingerprint;
1703    }
1704
1705    public String getFingerprint() {
1706        return axolotlFingerprint;
1707    }
1708
1709    public boolean isTrusted() {
1710        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1711        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1712        return s != null && s.isTrusted();
1713    }
1714
1715    private int getPreviousEncryption() {
1716        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1717            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1718                continue;
1719            }
1720            return iterator.getEncryption();
1721        }
1722        return ENCRYPTION_NONE;
1723    }
1724
1725    private int getNextEncryption() {
1726        if (this.conversation instanceof Conversation) {
1727            Conversation conversation = (Conversation) this.conversation;
1728            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1729                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1730                    continue;
1731                }
1732                return iterator.getEncryption();
1733            }
1734            return conversation.getNextEncryption();
1735        } else {
1736            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1737        }
1738    }
1739
1740    public boolean isValidInSession() {
1741        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1742        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1743
1744        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1745                || futureEncryption == ENCRYPTION_NONE
1746                || pastEncryption != futureEncryption;
1747
1748        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1749    }
1750
1751    private static int getCleanedEncryption(int encryption) {
1752        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1753            return ENCRYPTION_PGP;
1754        }
1755        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1756            return ENCRYPTION_AXOLOTL;
1757        }
1758        return encryption;
1759    }
1760
1761    public static boolean configurePrivateMessage(final Message message) {
1762        return configurePrivateMessage(message, false);
1763    }
1764
1765    public static boolean configurePrivateFileMessage(final Message message) {
1766        return configurePrivateMessage(message, true);
1767    }
1768
1769    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1770        final Conversation conversation;
1771        if (message.conversation instanceof Conversation) {
1772            conversation = (Conversation) message.conversation;
1773        } else {
1774            return false;
1775        }
1776        if (conversation.getMode() == Conversation.MODE_MULTI) {
1777            final Jid nextCounterpart = conversation.getNextCounterpart();
1778            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1779        }
1780        return false;
1781    }
1782
1783    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1784        final Conversation conversation;
1785        if (message.conversation instanceof Conversation) {
1786            conversation = (Conversation) message.conversation;
1787        } else {
1788            return false;
1789        }
1790        return configurePrivateMessage(conversation, message, counterpart, false);
1791    }
1792
1793    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1794        if (counterpart == null) {
1795            return false;
1796        }
1797        message.setCounterpart(counterpart);
1798        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1799        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1800        return true;
1801    }
1802
1803    public static class PlainTextSpan {}
1804}