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