Message.java

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