Message.java

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