Message.java

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