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 String getConversationUuid() {
 441        return conversationUuid;
 442    }
 443
 444    public Conversational getConversation() {
 445        return this.conversation;
 446    }
 447
 448    public Jid getCounterpart() {
 449        return counterpart;
 450    }
 451
 452    public void setCounterpart(final Jid counterpart) {
 453        this.counterpart = counterpart;
 454    }
 455
 456    public Contact getContact() {
 457        if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
 458            if (this.trueCounterpart != null) {
 459                return this.conversation.getAccount().getRoster()
 460                           .getContact(this.trueCounterpart);
 461            }
 462
 463            return this.conversation.getContact();
 464        } else {
 465            if (this.trueCounterpart == null) {
 466                return null;
 467            } else {
 468                return this.conversation.getAccount().getRoster()
 469                        .getContactFromContactList(this.trueCounterpart);
 470            }
 471        }
 472    }
 473
 474    public String getQuoteableBody() {
 475        return this.body;
 476    }
 477
 478    public String getBody() {
 479        StringBuilder body = new StringBuilder(this.body);
 480
 481        List<Element> fallbacks = getFallbacks();
 482        List<Pair<Integer, Integer>> spans = new ArrayList<>();
 483        for (Element fallback : fallbacks) {
 484            for (Element span : fallback.getChildren()) {
 485                if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
 486                if (span.getAttribute("start") == null || span.getAttribute("end") == null) return "";
 487                spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
 488            }
 489        }
 490        // Do them in reverse order so that span deletions don't affect the indexes of other spans
 491        spans.sort((x, y) -> y.first.compareTo(x.first));
 492        try {
 493            for (Pair<Integer, Integer> span : spans) {
 494                body.delete(span.first, span.second);
 495            }
 496        } catch (final StringIndexOutOfBoundsException e) { spans.clear(); }
 497
 498        if (spans.isEmpty() && getOob() != null) {
 499            return body.toString().replace(getOob().toString(), "");
 500        } else {
 501            return body.toString();
 502        }
 503    }
 504
 505    public synchronized void setBody(String body) {
 506        if (body == null) {
 507            throw new Error("You should not set the message body to null");
 508        }
 509        this.body = body;
 510        this.isGeoUri = null;
 511        this.isEmojisOnly = null;
 512        this.treatAsDownloadable = null;
 513    }
 514
 515    public synchronized void appendBody(String append) {
 516        this.body += append;
 517        this.isGeoUri = null;
 518        this.isEmojisOnly = null;
 519        this.treatAsDownloadable = null;
 520    }
 521
 522    public String getSubject() {
 523        return subject;
 524    }
 525
 526    public synchronized void setSubject(String subject) {
 527        this.subject = subject;
 528    }
 529
 530    public Element getThread() {
 531        if (this.payloads == null) return null;
 532
 533        for (Element el : this.payloads) {
 534            if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
 535                return el;
 536            }
 537        }
 538
 539        return null;
 540    }
 541
 542    public void setThread(Element thread) {
 543        payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
 544        addPayload(thread);
 545    }
 546
 547    public void setMucUser(MucOptions.User user) {
 548        this.user = new WeakReference<>(user);
 549    }
 550
 551    public boolean sameMucUser(Message otherMessage) {
 552        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 553        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 554        return thisUser != null && thisUser == otherUser;
 555    }
 556
 557    public String getErrorMessage() {
 558        return errorMessage;
 559    }
 560
 561    public boolean setErrorMessage(String message) {
 562        boolean changed = (message != null && !message.equals(errorMessage))
 563                || (message == null && errorMessage != null);
 564        this.errorMessage = message;
 565        return changed;
 566    }
 567
 568    public long getTimeReceived() {
 569        return timeReceived;
 570    }
 571
 572    public long getTimeSent() {
 573        return timeSent;
 574    }
 575
 576    public int getEncryption() {
 577        return encryption;
 578    }
 579
 580    public void setEncryption(int encryption) {
 581        this.encryption = encryption;
 582    }
 583
 584    public int getStatus() {
 585        return status;
 586    }
 587
 588    public void setStatus(int status) {
 589        this.status = status;
 590    }
 591
 592    public String getRelativeFilePath() {
 593        return this.relativeFilePath;
 594    }
 595
 596    public void setRelativeFilePath(String path) {
 597        this.relativeFilePath = path;
 598    }
 599
 600    public String getRemoteMsgId() {
 601        return this.remoteMsgId;
 602    }
 603
 604    public void setRemoteMsgId(String id) {
 605        this.remoteMsgId = id;
 606    }
 607
 608    public String getServerMsgId() {
 609        return this.serverMsgId;
 610    }
 611
 612    public void setServerMsgId(String id) {
 613        this.serverMsgId = id;
 614    }
 615
 616    public boolean isRead() {
 617        return this.read;
 618    }
 619
 620    public boolean isDeleted() {
 621        return this.deleted;
 622    }
 623
 624    public Element getModerated() {
 625        if (this.payloads == null) return null;
 626
 627        for (Element el : this.payloads) {
 628            if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
 629                return el;
 630            }
 631        }
 632
 633        return null;
 634    }
 635
 636    public void setDeleted(boolean deleted) {
 637        this.deleted = deleted;
 638    }
 639
 640    public void markRead() {
 641        this.read = true;
 642    }
 643
 644    public void markUnread() {
 645        this.read = false;
 646    }
 647
 648    public void setTime(long time) {
 649        this.timeSent = time;
 650    }
 651
 652    public void setTimeReceived(long time) {
 653        this.timeReceived = time;
 654    }
 655
 656    public String getEncryptedBody() {
 657        return this.encryptedBody;
 658    }
 659
 660    public void setEncryptedBody(String body) {
 661        this.encryptedBody = body;
 662    }
 663
 664    public int getType() {
 665        return this.type;
 666    }
 667
 668    public void setType(int type) {
 669        this.type = type;
 670    }
 671
 672    public boolean isCarbon() {
 673        return carbon;
 674    }
 675
 676    public void setCarbon(boolean carbon) {
 677        this.carbon = carbon;
 678    }
 679
 680    public void putEdited(String edited, String serverMsgId) {
 681        final Edit edit = new Edit(edited, serverMsgId);
 682        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 683            this.edits.add(edit);
 684        }
 685    }
 686
 687    boolean remoteMsgIdMatchInEdit(String id) {
 688        for (Edit edit : this.edits) {
 689            if (id.equals(edit.getEditedId())) {
 690                return true;
 691            }
 692        }
 693        return false;
 694    }
 695
 696    public String getBodyLanguage() {
 697        return this.bodyLanguage;
 698    }
 699
 700    public void setBodyLanguage(String language) {
 701        this.bodyLanguage = language;
 702    }
 703
 704    public boolean edited() {
 705        return this.edits.size() > 0;
 706    }
 707
 708    public void setTrueCounterpart(Jid trueCounterpart) {
 709        this.trueCounterpart = trueCounterpart;
 710    }
 711
 712    public Jid getTrueCounterpart() {
 713        return this.trueCounterpart;
 714    }
 715
 716    public Transferable getTransferable() {
 717        return this.transferable;
 718    }
 719
 720    public synchronized void setTransferable(Transferable transferable) {
 721        this.transferable = transferable;
 722    }
 723
 724    public boolean addReadByMarker(ReadByMarker readByMarker) {
 725        if (readByMarker.getRealJid() != null) {
 726            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 727                return false;
 728            }
 729        } else if (readByMarker.getFullJid() != null) {
 730            if (readByMarker.getFullJid().equals(counterpart)) {
 731                return false;
 732            }
 733        }
 734        if (this.readByMarkers.add(readByMarker)) {
 735            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 736                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 737                while (iterator.hasNext()) {
 738                    ReadByMarker marker = iterator.next();
 739                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 740                        iterator.remove();
 741                    }
 742                }
 743            }
 744            return true;
 745        } else {
 746            return false;
 747        }
 748    }
 749
 750    public Set<ReadByMarker> getReadByMarkers() {
 751        return ImmutableSet.copyOf(this.readByMarkers);
 752    }
 753
 754    boolean similar(Message message) {
 755        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 756            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 757        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 758            return true;
 759        } else if (this.body == null || this.counterpart == null) {
 760            return false;
 761        } else {
 762            String body, otherBody;
 763            if (this.hasFileOnRemoteHost()) {
 764                body = getFileParams().url;
 765                otherBody = message.body == null ? null : message.body.trim();
 766            } else {
 767                body = this.body;
 768                otherBody = message.body;
 769            }
 770            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 771            if (message.getRemoteMsgId() != null) {
 772                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 773                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 774                    return true;
 775                }
 776                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 777                        && matchingCounterpart
 778                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 779            } else {
 780                return this.remoteMsgId == null
 781                        && matchingCounterpart
 782                        && body.equals(otherBody)
 783                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 784            }
 785        }
 786    }
 787
 788    public Message next() {
 789        if (this.conversation instanceof Conversation) {
 790            final Conversation conversation = (Conversation) this.conversation;
 791            synchronized (conversation.messages) {
 792                if (this.mNextMessage == null) {
 793                    int index = conversation.messages.indexOf(this);
 794                    if (index < 0 || index >= conversation.messages.size() - 1) {
 795                        this.mNextMessage = null;
 796                    } else {
 797                        this.mNextMessage = conversation.messages.get(index + 1);
 798                    }
 799                }
 800                return this.mNextMessage;
 801            }
 802        } else {
 803            throw new AssertionError("Calling next should be disabled for stubs");
 804        }
 805    }
 806
 807    public Message prev() {
 808        if (this.conversation instanceof Conversation) {
 809            final Conversation conversation = (Conversation) this.conversation;
 810            synchronized (conversation.messages) {
 811                if (this.mPreviousMessage == null) {
 812                    int index = conversation.messages.indexOf(this);
 813                    if (index <= 0 || index > conversation.messages.size()) {
 814                        this.mPreviousMessage = null;
 815                    } else {
 816                        this.mPreviousMessage = conversation.messages.get(index - 1);
 817                    }
 818                }
 819            }
 820            return this.mPreviousMessage;
 821        } else {
 822            throw new AssertionError("Calling prev should be disabled for stubs");
 823        }
 824    }
 825
 826    public boolean isLastCorrectableMessage() {
 827        Message next = next();
 828        while (next != null) {
 829            if (next.isEditable()) {
 830                return false;
 831            }
 832            next = next.next();
 833        }
 834        return isEditable();
 835    }
 836
 837    public boolean isEditable() {
 838        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 839    }
 840
 841    public boolean mergeable(final Message message) {
 842        return message != null &&
 843                (message.getType() == Message.TYPE_TEXT &&
 844                        this.getTransferable() == null &&
 845                        message.getTransferable() == null &&
 846                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 847                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 848                        this.getType() == message.getType() &&
 849                        this.getSubject() != null &&
 850                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 851                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 852                        this.getCounterpart() != null &&
 853                        this.getCounterpart().equals(message.getCounterpart()) &&
 854                        this.edited() == message.edited() &&
 855                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 856                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 857                        !message.isGeoUri() &&
 858                        !this.isGeoUri() &&
 859                        !message.isOOb() &&
 860                        !this.isOOb() &&
 861                        !message.treatAsDownloadable() &&
 862                        !this.treatAsDownloadable() &&
 863                        !message.hasMeCommand() &&
 864                        !this.hasMeCommand() &&
 865                        !this.bodyIsOnlyEmojis() &&
 866                        !message.bodyIsOnlyEmojis() &&
 867                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 868                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 869                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 870                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 871                );
 872    }
 873
 874    private static boolean isStatusMergeable(int a, int b) {
 875        return a == b || (
 876                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 877                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 878                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 879                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 880                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 881        );
 882    }
 883
 884    private static boolean isEncryptionMergeable(final int a, final int b) {
 885        return a == b
 886                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 887                        .contains(a);
 888    }
 889
 890    public void setCounterparts(List<MucOptions.User> counterparts) {
 891        this.counterparts = counterparts;
 892    }
 893
 894    public List<MucOptions.User> getCounterparts() {
 895        return this.counterparts;
 896    }
 897
 898    @Override
 899    public int getAvatarBackgroundColor() {
 900        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 901            return Color.TRANSPARENT;
 902        } else {
 903            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 904        }
 905    }
 906
 907    @Override
 908    public String getAvatarName() {
 909        return UIHelper.getMessageDisplayName(this);
 910    }
 911
 912    public boolean isOOb() {
 913        return oob || getFileParams().url != null;
 914    }
 915
 916    public static class MergeSeparator {
 917    }
 918
 919    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 920        final Element html = getHtml();
 921        if (html == null || Build.VERSION.SDK_INT < 24) {
 922            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
 923        } else {
 924            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
 925                MessageUtils.filterLtrRtl(html.toString()).trim(),
 926                Html.FROM_HTML_MODE_COMPACT,
 927                (source) -> {
 928                   try {
 929                       if (thumbnailer == null) return fallbackImg;
 930                       Cid cid = BobTransfer.cid(new URI(source));
 931                       if (cid == null) return fallbackImg;
 932                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
 933                       if (thumbnail == null) return fallbackImg;
 934                       return thumbnail;
 935                   } catch (final URISyntaxException e) {
 936                       return fallbackImg;
 937                   }
 938                },
 939                (opening, tag, output, xmlReader) -> {}
 940            ));
 941
 942            // Make images clickable and long-clickable with BetterLinkMovementMethod
 943            ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
 944            for (ImageSpan span : imageSpans) {
 945                final int start = spannable.getSpanStart(span);
 946                final int end = spannable.getSpanEnd(span);
 947
 948                ClickableSpan click_span = new ClickableSpan() {
 949                    @Override
 950                    public void onClick(View widget) { }
 951                };
 952
 953                spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
 954            }
 955
 956            // https://stackoverflow.com/a/10187511/8611
 957            int i = spannable.length();
 958            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
 959            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
 960        }
 961    }
 962
 963    public SpannableStringBuilder getMergedBody() {
 964        return getMergedBody(null, null);
 965    }
 966
 967    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 968        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
 969        Message current = this;
 970        while (current.mergeable(current.next())) {
 971            current = current.next();
 972            if (current == null) {
 973                break;
 974            }
 975            body.append("\n\n");
 976            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 977                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 978            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
 979        }
 980        return body;
 981    }
 982
 983    public boolean hasMeCommand() {
 984        return this.body.trim().startsWith(ME_COMMAND);
 985    }
 986
 987    public int getMergedStatus() {
 988        int status = this.status;
 989        Message current = this;
 990        while (current.mergeable(current.next())) {
 991            current = current.next();
 992            if (current == null) {
 993                break;
 994            }
 995            status = current.status;
 996        }
 997        return status;
 998    }
 999
1000    public long getMergedTimeSent() {
1001        long time = this.timeSent;
1002        Message current = this;
1003        while (current.mergeable(current.next())) {
1004            current = current.next();
1005            if (current == null) {
1006                break;
1007            }
1008            time = current.timeSent;
1009        }
1010        return time;
1011    }
1012
1013    public boolean wasMergedIntoPrevious() {
1014        Message prev = this.prev();
1015        return prev != null && prev.mergeable(this);
1016    }
1017
1018    public boolean trusted() {
1019        Contact contact = this.getContact();
1020        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1021    }
1022
1023    public boolean fixCounterpart() {
1024        final Presences presences = conversation.getContact().getPresences();
1025        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1026            return true;
1027        } else if (presences.size() >= 1) {
1028            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1029            return true;
1030        } else {
1031            counterpart = null;
1032            return false;
1033        }
1034    }
1035
1036    public void setUuid(String uuid) {
1037        this.uuid = uuid;
1038    }
1039
1040    public String getEditedId() {
1041        if (edits.size() > 0) {
1042            return edits.get(edits.size() - 1).getEditedId();
1043        } else {
1044            throw new IllegalStateException("Attempting to store unedited message");
1045        }
1046    }
1047
1048    public String getEditedIdWireFormat() {
1049        if (edits.size() > 0) {
1050            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1051        } else {
1052            throw new IllegalStateException("Attempting to store unedited message");
1053        }
1054    }
1055
1056    public URI getOob() {
1057        final String url = getFileParams().url;
1058        try {
1059            return url == null ? null : new URI(url);
1060        } catch (final URISyntaxException e) {
1061            return null;
1062        }
1063    }
1064
1065    public void clearPayloads() {
1066        this.payloads.clear();
1067    }
1068
1069    public void addPayload(Element el) {
1070        if (el == null) return;
1071
1072        this.payloads.add(el);
1073    }
1074
1075    public List<Element> getPayloads() {
1076       return new ArrayList<>(this.payloads);
1077    }
1078
1079    public List<Element> getFallbacks() {
1080        List<Element> fallbacks = new ArrayList<>();
1081
1082        if (this.payloads == null) return fallbacks;
1083
1084        for (Element el : this.payloads) {
1085            if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1086                final String fallbackFor = el.getAttribute("for");
1087                if (fallbackFor == null) continue;
1088                if (fallbackFor.equals("http://jabber.org/protocol/address") || fallbackFor.equals(Namespace.OOB)) {
1089                    fallbacks.add(el);
1090                }
1091            }
1092        }
1093
1094        return fallbacks;
1095    }
1096
1097    public Element getHtml() {
1098        if (this.payloads == null) return null;
1099
1100        for (Element el : this.payloads) {
1101            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1102                return el.getChildren().get(0);
1103            }
1104        }
1105
1106        return null;
1107   }
1108
1109    public List<Element> getCommands() {
1110        if (this.payloads == null) return null;
1111
1112        for (Element el : this.payloads) {
1113            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1114                return el.getChildren();
1115            }
1116        }
1117
1118        return null;
1119    }
1120
1121    public String getMimeType() {
1122        String extension;
1123        if (relativeFilePath != null) {
1124            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1125        } else {
1126            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1127            if (url == null) {
1128                return null;
1129            }
1130            extension = MimeUtils.extractRelevantExtension(url);
1131        }
1132        return MimeUtils.guessMimeTypeFromExtension(extension);
1133    }
1134
1135    public synchronized boolean treatAsDownloadable() {
1136        if (treatAsDownloadable == null) {
1137            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1138        }
1139        return treatAsDownloadable;
1140    }
1141
1142    public synchronized boolean bodyIsOnlyEmojis() {
1143        if (isEmojisOnly == null) {
1144            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1145        }
1146        return isEmojisOnly;
1147    }
1148
1149    public synchronized boolean isGeoUri() {
1150        if (isGeoUri == null) {
1151            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1152        }
1153        return isGeoUri;
1154    }
1155
1156    protected List<Element> getSims() {
1157        return payloads.stream().filter(el ->
1158            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1159            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1160        ).collect(Collectors.toList());
1161    }
1162
1163    public synchronized void resetFileParams() {
1164        this.fileParams = null;
1165    }
1166
1167    public synchronized void setFileParams(FileParams fileParams) {
1168        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1169            fileParams.sims = this.fileParams.sims;
1170        }
1171        this.fileParams = fileParams;
1172        if (fileParams != null && getSims().isEmpty()) {
1173            addPayload(fileParams.toSims());
1174        }
1175    }
1176
1177    public synchronized FileParams getFileParams() {
1178        if (fileParams == null) {
1179            List<Element> sims = getSims();
1180            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1181            if (this.transferable != null) {
1182                fileParams.size = this.transferable.getFileSize();
1183            }
1184        }
1185
1186        return fileParams;
1187    }
1188
1189    private static int parseInt(String value) {
1190        try {
1191            return Integer.parseInt(value);
1192        } catch (NumberFormatException e) {
1193            return 0;
1194        }
1195    }
1196
1197    public void untie() {
1198        this.mNextMessage = null;
1199        this.mPreviousMessage = null;
1200    }
1201
1202    public boolean isPrivateMessage() {
1203        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1204    }
1205
1206    public boolean isFileOrImage() {
1207        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1208    }
1209
1210
1211    public boolean isTypeText() {
1212        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1213    }
1214
1215    public boolean hasFileOnRemoteHost() {
1216        return isFileOrImage() && getFileParams().url != null;
1217    }
1218
1219    public boolean needsUploading() {
1220        return isFileOrImage() && getFileParams().url == null;
1221    }
1222
1223    public static class FileParams {
1224        public String url;
1225        public Long size = null;
1226        public int width = 0;
1227        public int height = 0;
1228        public int runtime = 0;
1229        public Element sims = null;
1230
1231        public FileParams() { }
1232
1233        public FileParams(Element el) {
1234            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1235                this.url = el.findChildContent("url", Namespace.OOB);
1236            }
1237            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1238                sims = el;
1239                final String refUri = el.getAttribute("uri");
1240                if (refUri != null) url = refUri;
1241                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1242                if (mediaSharing != null) {
1243                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1244                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1245                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1246                    if (file != null) {
1247                        String sizeS = file.findChildContent("size", file.getNamespace());
1248                        if (sizeS != null) size = new Long(sizeS);
1249                        String widthS = file.findChildContent("width", "https://schema.org/");
1250                        if (widthS != null) width = parseInt(widthS);
1251                        String heightS = file.findChildContent("height", "https://schema.org/");
1252                        if (heightS != null) height = parseInt(heightS);
1253                        String durationS = file.findChildContent("duration", "https://schema.org/");
1254                        if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1255                    }
1256
1257                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1258                    if (sources != null) {
1259                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1260                        if (ref != null) url = ref.getAttribute("uri");
1261                    }
1262                }
1263            }
1264        }
1265
1266        public FileParams(String ser) {
1267            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1268            switch (parts.length) {
1269                case 1:
1270                    try {
1271                        this.size = Long.parseLong(parts[0]);
1272                    } catch (final NumberFormatException e) {
1273                        this.url = URL.tryParse(parts[0]);
1274                    }
1275                    break;
1276                case 5:
1277                    this.runtime = parseInt(parts[4]);
1278                case 4:
1279                    this.width = parseInt(parts[2]);
1280                    this.height = parseInt(parts[3]);
1281                case 2:
1282                    this.url = URL.tryParse(parts[0]);
1283                    this.size = Longs.tryParse(parts[1]);
1284                    break;
1285                case 3:
1286                    this.size = Longs.tryParse(parts[0]);
1287                    this.width = parseInt(parts[1]);
1288                    this.height = parseInt(parts[2]);
1289                    break;
1290            }
1291        }
1292
1293        public long getSize() {
1294            return size == null ? 0 : size;
1295        }
1296
1297        public String getName() {
1298            Element file = getFileElement();
1299            if (file == null) return null;
1300
1301            return file.findChildContent("name", file.getNamespace());
1302        }
1303
1304        public void setName(final String name) {
1305            if (sims == null) toSims();
1306            Element file = getFileElement();
1307
1308            for (Element child : file.getChildren()) {
1309                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1310                    file.removeChild(child);
1311                }
1312            }
1313
1314            if (name != null) {
1315                file.addChild("name", file.getNamespace()).setContent(name);
1316            }
1317        }
1318
1319        public Element toSims() {
1320            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1321            sims.setAttribute("type", "data");
1322            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1323            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1324
1325            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1326            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1327            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1328            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1329
1330            file.removeChild(file.findChild("size", file.getNamespace()));
1331            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1332
1333            file.removeChild(file.findChild("width", "https://schema.org/"));
1334            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1335
1336            file.removeChild(file.findChild("height", "https://schema.org/"));
1337            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1338
1339            file.removeChild(file.findChild("duration", "https://schema.org/"));
1340            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1341
1342            if (url != null) {
1343                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1344                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1345
1346                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1347                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1348                source.setAttribute("type", "data");
1349                source.setAttribute("uri", url);
1350            }
1351
1352            return sims;
1353        }
1354
1355        protected Element getFileElement() {
1356            Element file = null;
1357            if (sims == null) return file;
1358
1359            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1360            if (mediaSharing == null) return file;
1361            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1362            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1363            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1364            return file;
1365        }
1366
1367        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1368            if (sims == null) toSims();
1369            Element file = getFileElement();
1370
1371            for (Element child : file.getChildren()) {
1372                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1373                    file.removeChild(child);
1374                }
1375            }
1376
1377            for (Cid cid : cids) {
1378                file.addChild("hash", "urn:xmpp:hashes:2")
1379                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1380                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1381            }
1382        }
1383
1384        public List<Cid> getCids() {
1385            List<Cid> cids = new ArrayList<>();
1386            Element file = getFileElement();
1387            if (file == null) return cids;
1388
1389            for (Element child : file.getChildren()) {
1390                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1391                    try {
1392                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1393                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1394                }
1395            }
1396
1397            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1398
1399            return cids;
1400        }
1401
1402        public List<Element> getThumbnails() {
1403            List<Element> thumbs = new ArrayList<>();
1404            Element file = getFileElement();
1405            if (file == null) return thumbs;
1406
1407            for (Element child : file.getChildren()) {
1408                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1409                    thumbs.add(child);
1410                }
1411            }
1412
1413            return thumbs;
1414        }
1415
1416        public String toString() {
1417            final StringBuilder builder = new StringBuilder();
1418            if (url != null) builder.append(url);
1419            if (size != null) builder.append('|').append(size.toString());
1420            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1421            if (height > 0 || runtime > 0) builder.append('|').append(height);
1422            if (runtime > 0) builder.append('|').append(runtime);
1423            return builder.toString();
1424        }
1425
1426        public boolean equals(Object o) {
1427            if (!(o instanceof FileParams)) return false;
1428            if (url == null) return false;
1429
1430            return url.equals(((FileParams) o).url);
1431        }
1432
1433        public int hashCode() {
1434            return url == null ? super.hashCode() : url.hashCode();
1435        }
1436    }
1437
1438    public void setFingerprint(String fingerprint) {
1439        this.axolotlFingerprint = fingerprint;
1440    }
1441
1442    public String getFingerprint() {
1443        return axolotlFingerprint;
1444    }
1445
1446    public boolean isTrusted() {
1447        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1448        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1449        return s != null && s.isTrusted();
1450    }
1451
1452    private int getPreviousEncryption() {
1453        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1454            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1455                continue;
1456            }
1457            return iterator.getEncryption();
1458        }
1459        return ENCRYPTION_NONE;
1460    }
1461
1462    private int getNextEncryption() {
1463        if (this.conversation instanceof Conversation) {
1464            Conversation conversation = (Conversation) this.conversation;
1465            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1466                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1467                    continue;
1468                }
1469                return iterator.getEncryption();
1470            }
1471            return conversation.getNextEncryption();
1472        } else {
1473            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1474        }
1475    }
1476
1477    public boolean isValidInSession() {
1478        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1479        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1480
1481        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1482                || futureEncryption == ENCRYPTION_NONE
1483                || pastEncryption != futureEncryption;
1484
1485        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1486    }
1487
1488    private static int getCleanedEncryption(int encryption) {
1489        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1490            return ENCRYPTION_PGP;
1491        }
1492        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1493            return ENCRYPTION_AXOLOTL;
1494        }
1495        return encryption;
1496    }
1497
1498    public static boolean configurePrivateMessage(final Message message) {
1499        return configurePrivateMessage(message, false);
1500    }
1501
1502    public static boolean configurePrivateFileMessage(final Message message) {
1503        return configurePrivateMessage(message, true);
1504    }
1505
1506    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1507        final Conversation conversation;
1508        if (message.conversation instanceof Conversation) {
1509            conversation = (Conversation) message.conversation;
1510        } else {
1511            return false;
1512        }
1513        if (conversation.getMode() == Conversation.MODE_MULTI) {
1514            final Jid nextCounterpart = conversation.getNextCounterpart();
1515            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1516        }
1517        return false;
1518    }
1519
1520    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1521        final Conversation conversation;
1522        if (message.conversation instanceof Conversation) {
1523            conversation = (Conversation) message.conversation;
1524        } else {
1525            return false;
1526        }
1527        return configurePrivateMessage(conversation, message, counterpart, false);
1528    }
1529
1530    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1531        if (counterpart == null) {
1532            return false;
1533        }
1534        message.setCounterpart(counterpart);
1535        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1536        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1537        return true;
1538    }
1539}