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