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