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