Message.java

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