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