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