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