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