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