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