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