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