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