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