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