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