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