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