Message.java

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