Message.java

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