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