Message.java

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