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