Message.java

   1package eu.siacs.conversations.entities;
   2
   3import android.content.ContentValues;
   4import android.database.Cursor;
   5import android.graphics.drawable.Drawable;
   6import android.graphics.Color;
   7import android.os.Build;
   8import android.text.Html;
   9import android.text.SpannableStringBuilder;
  10import android.text.Spanned;
  11import android.text.style.ImageSpan;
  12import android.text.style.ClickableSpan;
  13import android.util.Base64;
  14import android.util.Log;
  15import android.util.Pair;
  16import android.view.View;
  17
  18import com.cheogram.android.BobTransfer;
  19import com.cheogram.android.GetThumbnailForCid;
  20
  21import com.google.common.io.ByteSource;
  22import com.google.common.base.Strings;
  23import com.google.common.collect.ImmutableSet;
  24import com.google.common.primitives.Longs;
  25
  26import org.json.JSONException;
  27
  28import java.lang.ref.WeakReference;
  29import java.io.IOException;
  30import java.net.URI;
  31import java.net.URISyntaxException;
  32import java.time.Duration;
  33import java.security.NoSuchAlgorithmException;
  34import java.util.ArrayList;
  35import java.util.Arrays;
  36import java.util.HashSet;
  37import java.util.Iterator;
  38import java.util.List;
  39import java.util.Set;
  40import java.util.stream.Collectors;
  41import java.util.concurrent.CopyOnWriteArraySet;
  42
  43import io.ipfs.cid.Cid;
  44
  45import eu.siacs.conversations.Config;
  46import eu.siacs.conversations.crypto.axolotl.AxolotlService;
  47import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
  48import eu.siacs.conversations.http.URL;
  49import eu.siacs.conversations.services.AvatarService;
  50import eu.siacs.conversations.ui.util.PresenceSelector;
  51import eu.siacs.conversations.ui.util.QuoteHelper;
  52import eu.siacs.conversations.utils.CryptoHelper;
  53import eu.siacs.conversations.utils.Emoticons;
  54import eu.siacs.conversations.utils.GeoHelper;
  55import eu.siacs.conversations.utils.MessageUtils;
  56import eu.siacs.conversations.utils.MimeUtils;
  57import eu.siacs.conversations.utils.StringUtils;
  58import eu.siacs.conversations.utils.UIHelper;
  59import eu.siacs.conversations.xmpp.Jid;
  60import eu.siacs.conversations.xml.Element;
  61import eu.siacs.conversations.xml.Namespace;
  62import eu.siacs.conversations.xml.Tag;
  63import eu.siacs.conversations.xml.XmlReader;
  64
  65public class Message extends AbstractEntity implements AvatarService.Avatarable {
  66
  67    public static final String TABLENAME = "messages";
  68
  69    public static final int STATUS_RECEIVED = 0;
  70    public static final int STATUS_UNSEND = 1;
  71    public static final int STATUS_SEND = 2;
  72    public static final int STATUS_SEND_FAILED = 3;
  73    public static final int STATUS_WAITING = 5;
  74    public static final int STATUS_OFFERED = 6;
  75    public static final int STATUS_SEND_RECEIVED = 7;
  76    public static final int STATUS_SEND_DISPLAYED = 8;
  77
  78    public static final int ENCRYPTION_NONE = 0;
  79    public static final int ENCRYPTION_PGP = 1;
  80    public static final int ENCRYPTION_OTR = 2;
  81    public static final int ENCRYPTION_DECRYPTED = 3;
  82    public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
  83    public static final int ENCRYPTION_AXOLOTL = 5;
  84    public static final int ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE = 6;
  85    public static final int ENCRYPTION_AXOLOTL_FAILED = 7;
  86
  87    public static final int TYPE_TEXT = 0;
  88    public static final int TYPE_IMAGE = 1;
  89    public static final int TYPE_FILE = 2;
  90    public static final int TYPE_STATUS = 3;
  91    public static final int TYPE_PRIVATE = 4;
  92    public static final int TYPE_PRIVATE_FILE = 5;
  93    public static final int TYPE_RTP_SESSION = 6;
  94
  95    public static final String CONVERSATION = "conversationUuid";
  96    public static final String COUNTERPART = "counterpart";
  97    public static final String TRUE_COUNTERPART = "trueCounterpart";
  98    public static final String BODY = "body";
  99    public static final String BODY_LANGUAGE = "bodyLanguage";
 100    public static final String TIME_SENT = "timeSent";
 101    public static final String ENCRYPTION = "encryption";
 102    public static final String STATUS = "status";
 103    public static final String TYPE = "type";
 104    public static final String CARBON = "carbon";
 105    public static final String OOB = "oob";
 106    public static final String EDITED = "edited";
 107    public static final String REMOTE_MSG_ID = "remoteMsgId";
 108    public static final String SERVER_MSG_ID = "serverMsgId";
 109    public static final String RELATIVE_FILE_PATH = "relativeFilePath";
 110    public static final String FINGERPRINT = "axolotl_fingerprint";
 111    public static final String READ = "read";
 112    public static final String ERROR_MESSAGE = "errorMsg";
 113    public static final String READ_BY_MARKERS = "readByMarkers";
 114    public static final String MARKABLE = "markable";
 115    public static final String DELETED = "deleted";
 116    public static final String ME_COMMAND = "/me ";
 117
 118    public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
 119
 120
 121    public boolean markable = false;
 122    protected String conversationUuid;
 123    protected Jid counterpart;
 124    protected Jid trueCounterpart;
 125    protected String body;
 126    protected String subject;
 127    protected String encryptedBody;
 128    protected long timeSent;
 129    protected long timeReceived;
 130    protected int encryption;
 131    protected int status;
 132    protected int type;
 133    protected boolean deleted = false;
 134    protected boolean carbon = false;
 135    private boolean oob = false;
 136    protected List<Element> payloads = new ArrayList<>();
 137    protected List<Edit> edits = new ArrayList<>();
 138    protected String relativeFilePath;
 139    protected boolean read = true;
 140    protected String remoteMsgId = null;
 141    private String bodyLanguage = null;
 142    protected String serverMsgId = null;
 143    private final Conversational conversation;
 144    protected Transferable transferable = null;
 145    private Message mNextMessage = null;
 146    private Message mPreviousMessage = null;
 147    private String axolotlFingerprint = null;
 148    private String errorMessage = null;
 149    private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
 150
 151    private Boolean isGeoUri = null;
 152    private Boolean isEmojisOnly = null;
 153    private Boolean treatAsDownloadable = null;
 154    private FileParams fileParams = null;
 155    private List<MucOptions.User> counterparts;
 156    private WeakReference<MucOptions.User> user;
 157
 158    protected Message(Conversational conversation) {
 159        this.conversation = conversation;
 160    }
 161
 162    public Message(Conversational conversation, String body, int encryption) {
 163        this(conversation, body, encryption, STATUS_UNSEND);
 164    }
 165
 166    public Message(Conversational conversation, String body, int encryption, int status) {
 167        this(conversation, java.util.UUID.randomUUID().toString(),
 168                conversation.getUuid(),
 169                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 170                null,
 171                body,
 172                System.currentTimeMillis(),
 173                encryption,
 174                status,
 175                TYPE_TEXT,
 176                false,
 177                null,
 178                null,
 179                null,
 180                null,
 181                true,
 182                null,
 183                false,
 184                null,
 185                null,
 186                false,
 187                false,
 188                null,
 189                System.currentTimeMillis(),
 190                null,
 191                null,
 192                null);
 193    }
 194
 195    public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
 196        this(conversation, java.util.UUID.randomUUID().toString(),
 197                conversation.getUuid(),
 198                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 199                null,
 200                null,
 201                System.currentTimeMillis(),
 202                Message.ENCRYPTION_NONE,
 203                status,
 204                type,
 205                false,
 206                remoteMsgId,
 207                null,
 208                null,
 209                null,
 210                true,
 211                null,
 212                false,
 213                null,
 214                null,
 215                false,
 216                false,
 217                null,
 218                System.currentTimeMillis(),
 219                null,
 220                null,
 221                null);
 222    }
 223
 224    protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
 225                      final Jid trueCounterpart, final String body, final long timeSent,
 226                      final int encryption, final int status, final int type, final boolean carbon,
 227                      final String remoteMsgId, final String relativeFilePath,
 228                      final String serverMsgId, final String fingerprint, final boolean read,
 229                      final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
 230                      final boolean markable, final boolean deleted, final String bodyLanguage, final long timeReceived, final String subject, final String fileParams, final List<Element> payloads) {
 231        this.conversation = conversation;
 232        this.uuid = uuid;
 233        this.conversationUuid = conversationUUid;
 234        this.counterpart = counterpart;
 235        this.trueCounterpart = trueCounterpart;
 236        this.body = body == null ? "" : body;
 237        this.timeSent = timeSent;
 238        this.encryption = encryption;
 239        this.status = status;
 240        this.type = type;
 241        this.carbon = carbon;
 242        this.remoteMsgId = remoteMsgId;
 243        this.relativeFilePath = relativeFilePath;
 244        this.serverMsgId = serverMsgId;
 245        this.axolotlFingerprint = fingerprint;
 246        this.read = read;
 247        this.edits = Edit.fromJson(edited);
 248        this.oob = oob;
 249        this.errorMessage = errorMessage;
 250        this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
 251        this.markable = markable;
 252        this.deleted = deleted;
 253        this.bodyLanguage = bodyLanguage;
 254        this.timeReceived = timeReceived;
 255        this.subject = subject;
 256        if (payloads != null) this.payloads = payloads;
 257        if (fileParams != null && getSims().isEmpty()) this.fileParams = new FileParams(fileParams);
 258    }
 259
 260    public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
 261        String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
 262        List<Element> payloads = new ArrayList<>();
 263        if (payloadsStr != null) {
 264            final XmlReader xmlReader = new XmlReader();
 265            xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
 266            Tag tag;
 267            while ((tag = xmlReader.readTag()) != null) {
 268                payloads.add(xmlReader.readElement(tag));
 269            }
 270        }
 271
 272        return new Message(conversation,
 273                cursor.getString(cursor.getColumnIndex(UUID)),
 274                cursor.getString(cursor.getColumnIndex(CONVERSATION)),
 275                fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
 276                fromString(cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART))),
 277                cursor.getString(cursor.getColumnIndex(BODY)),
 278                cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
 279                cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
 280                cursor.getInt(cursor.getColumnIndex(STATUS)),
 281                cursor.getInt(cursor.getColumnIndex(TYPE)),
 282                cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
 283                cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
 284                cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
 285                cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
 286                cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
 287                cursor.getInt(cursor.getColumnIndex(READ)) > 0,
 288                cursor.getString(cursor.getColumnIndex(EDITED)),
 289                cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
 290                cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
 291                ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))),
 292                cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
 293                cursor.getInt(cursor.getColumnIndex(DELETED)) > 0,
 294                cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
 295                cursor.getLong(cursor.getColumnIndex(cursor.isNull(cursor.getColumnIndex("timeReceived")) ? TIME_SENT : "timeReceived")),
 296                cursor.getString(cursor.getColumnIndex("subject")),
 297                cursor.getString(cursor.getColumnIndex("fileParams")),
 298                payloads
 299        );
 300    }
 301
 302    private static Jid fromString(String value) {
 303        try {
 304            if (value != null) {
 305                return Jid.of(value);
 306            }
 307        } catch (IllegalArgumentException e) {
 308            return null;
 309        }
 310        return null;
 311    }
 312
 313    public static Message createStatusMessage(Conversation conversation, String body) {
 314        final Message message = new Message(conversation);
 315        message.setType(Message.TYPE_STATUS);
 316        message.setStatus(Message.STATUS_RECEIVED);
 317        message.body = body;
 318        return message;
 319    }
 320
 321    public static Message createLoadMoreMessage(Conversation conversation) {
 322        final Message message = new Message(conversation);
 323        message.setType(Message.TYPE_STATUS);
 324        message.body = "LOAD_MORE";
 325        return message;
 326    }
 327
 328    public ContentValues getCheogramContentValues() {
 329        ContentValues values = new ContentValues();
 330        values.put(UUID, uuid);
 331        values.put("subject", subject);
 332        values.put("fileParams", fileParams == null ? null : fileParams.toString());
 333        if (fileParams != null && !fileParams.isEmpty()) {
 334            List<Element> sims = getSims();
 335            if (sims.isEmpty()) {
 336                addPayload(fileParams.toSims());
 337            } else {
 338                sims.get(0).replaceChildren(fileParams.toSims().getChildren());
 339            }
 340        }
 341        values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
 342        return values;
 343    }
 344
 345    @Override
 346    public ContentValues getContentValues() {
 347        ContentValues values = new ContentValues();
 348        values.put(UUID, uuid);
 349        values.put(CONVERSATION, conversationUuid);
 350        if (counterpart == null) {
 351            values.putNull(COUNTERPART);
 352        } else {
 353            values.put(COUNTERPART, counterpart.toString());
 354        }
 355        if (trueCounterpart == null) {
 356            values.putNull(TRUE_COUNTERPART);
 357        } else {
 358            values.put(TRUE_COUNTERPART, trueCounterpart.toString());
 359        }
 360        values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
 361        values.put(TIME_SENT, timeSent);
 362        values.put(ENCRYPTION, encryption);
 363        values.put(STATUS, status);
 364        values.put(TYPE, type);
 365        values.put(CARBON, carbon ? 1 : 0);
 366        values.put(REMOTE_MSG_ID, remoteMsgId);
 367        values.put(RELATIVE_FILE_PATH, relativeFilePath);
 368        values.put(SERVER_MSG_ID, serverMsgId);
 369        values.put(FINGERPRINT, axolotlFingerprint);
 370        values.put(READ, read ? 1 : 0);
 371        try {
 372            values.put(EDITED, Edit.toJson(edits));
 373        } catch (JSONException e) {
 374            Log.e(Config.LOGTAG, "error persisting json for edits", e);
 375        }
 376        values.put(OOB, oob ? 1 : 0);
 377        values.put(ERROR_MESSAGE, errorMessage);
 378        values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
 379        values.put(MARKABLE, markable ? 1 : 0);
 380        values.put(DELETED, deleted ? 1 : 0);
 381        values.put(BODY_LANGUAGE, bodyLanguage);
 382        return values;
 383    }
 384
 385    public String replyId() {
 386        return conversation.getMode() == Conversation.MODE_MULTI ? getServerMsgId() : getRemoteMsgId();
 387	 }
 388
 389    public Message reply() {
 390        Message m = new Message(conversation, QuoteHelper.quote(MessageUtils.prepareQuote(this)) + "\n", ENCRYPTION_NONE);
 391        m.setThread(getThread());
 392        m.addPayload(
 393            new Element("reply", "urn:xmpp:reply:0")
 394                .setAttribute("to", getCounterpart())
 395                .setAttribute("id", replyId())
 396        );
 397        final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0");
 398        fallback.addChild("body", "urn:xmpp:fallback:0")
 399                .setAttribute("start", "0")
 400                .setAttribute("end", "" + m.body.codePointCount(0, m.body.length()));
 401        m.addPayload(fallback);
 402        return m;
 403    }
 404
 405    public Message react(String emoji) {
 406        Set<String> emojis = new HashSet<>();
 407        if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(replyId(), null);
 408        emojis.add(emoji);
 409        final Message m = reply();
 410        m.appendBody(emoji);
 411        final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
 412        fallback.addChild("body", "urn:xmpp:fallback:0");
 413        m.addPayload(fallback);
 414        final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", replyId());
 415        for (String oneEmoji : emojis) {
 416            reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji);
 417        }
 418        m.addPayload(reactions);
 419        return m;
 420    }
 421
 422    public void setReactions(Element reactions) {
 423        if (this.payloads != null) {
 424            this.payloads.remove(getReactions());
 425        }
 426        addPayload(reactions);
 427    }
 428
 429    public Element getReactions() {
 430        if (this.payloads == null) return null;
 431
 432        for (Element el : this.payloads) {
 433            if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
 434                return el;
 435            }
 436        }
 437
 438        return null;
 439    }
 440
 441    public Element getReply() {
 442        if (this.payloads == null) return null;
 443
 444        for (Element el : this.payloads) {
 445            if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) {
 446                return el;
 447            }
 448        }
 449
 450        return null;
 451    }
 452
 453    public boolean isAttention() {
 454        if (this.payloads == null) return false;
 455
 456        for (Element el : this.payloads) {
 457            if (el.getName().equals("attention") && el.getNamespace().equals("urn:xmpp:attention:0")) {
 458                return true;
 459            }
 460        }
 461
 462        return false;
 463    }
 464
 465    public String getConversationUuid() {
 466        return conversationUuid;
 467    }
 468
 469    public Conversational getConversation() {
 470        return this.conversation;
 471    }
 472
 473    public Jid getCounterpart() {
 474        return counterpart;
 475    }
 476
 477    public void setCounterpart(final Jid counterpart) {
 478        this.counterpart = counterpart;
 479    }
 480
 481    public Contact getContact() {
 482        if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
 483            if (this.trueCounterpart != null) {
 484                return this.conversation.getAccount().getRoster()
 485                           .getContact(this.trueCounterpart);
 486            }
 487
 488            return this.conversation.getContact();
 489        } else {
 490            if (this.trueCounterpart == null) {
 491                return null;
 492            } else {
 493                return this.conversation.getAccount().getRoster()
 494                        .getContactFromContactList(this.trueCounterpart);
 495            }
 496        }
 497    }
 498
 499    public String getQuoteableBody() {
 500        return this.body;
 501    }
 502
 503    public String getBody() {
 504        StringBuilder body = new StringBuilder(this.body);
 505
 506        List<Element> fallbacks = getFallbacks("http://jabber.org/protocol/address", Namespace.OOB);
 507        List<Pair<Integer, Integer>> spans = new ArrayList<>();
 508        for (Element fallback : fallbacks) {
 509            for (Element span : fallback.getChildren()) {
 510                if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
 511                if (span.getAttribute("start") == null || span.getAttribute("end") == null) return "";
 512                spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
 513            }
 514        }
 515        // Do them in reverse order so that span deletions don't affect the indexes of other spans
 516        spans.sort((x, y) -> y.first.compareTo(x.first));
 517        try {
 518            for (Pair<Integer, Integer> span : spans) {
 519                body.delete(body.offsetByCodePoints(0, span.first.intValue()), body.offsetByCodePoints(0, span.second.intValue()));
 520            }
 521        } catch (final IndexOutOfBoundsException e) { spans.clear(); }
 522
 523        if (spans.isEmpty() && getOob() != null) {
 524            return body.toString().replace(getOob().toString(), "");
 525        } else if (spans.isEmpty() && isGeoUri()) {
 526            return "";
 527        } else {
 528            return body.toString();
 529        }
 530    }
 531
 532    public synchronized void clearFallbacks(String... includeFor) {
 533        this.payloads.removeAll(getFallbacks(includeFor));
 534    }
 535
 536    public synchronized void setBody(String body) {
 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(String... includeFor) {
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                for (String includeOne : includeFor) {
1117                    if (fallbackFor.equals(includeOne)) {
1118                        fallbacks.add(el);
1119                        break;
1120                    }
1121                }
1122            }
1123        }
1124
1125        return fallbacks;
1126    }
1127
1128    public Element getHtml() {
1129        if (this.payloads == null) return null;
1130
1131        for (Element el : this.payloads) {
1132            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1133                return el.getChildren().get(0);
1134            }
1135        }
1136
1137        return null;
1138   }
1139
1140    public List<Element> getCommands() {
1141        if (this.payloads == null) return null;
1142
1143        for (Element el : this.payloads) {
1144            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1145                return el.getChildren();
1146            }
1147        }
1148
1149        return null;
1150    }
1151
1152    public String getMimeType() {
1153        String extension;
1154        if (relativeFilePath != null) {
1155            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1156        } else {
1157            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1158            if (url == null) {
1159                return null;
1160            }
1161            extension = MimeUtils.extractRelevantExtension(url);
1162        }
1163        return MimeUtils.guessMimeTypeFromExtension(extension);
1164    }
1165
1166    public synchronized boolean treatAsDownloadable() {
1167        if (treatAsDownloadable == null) {
1168            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1169        }
1170        return treatAsDownloadable;
1171    }
1172
1173    public synchronized boolean bodyIsOnlyEmojis() {
1174        if (isEmojisOnly == null) {
1175            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1176        }
1177        return isEmojisOnly;
1178    }
1179
1180    public synchronized boolean isGeoUri() {
1181        if (isGeoUri == null) {
1182            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1183        }
1184        return isGeoUri;
1185    }
1186
1187    protected List<Element> getSims() {
1188        return payloads.stream().filter(el ->
1189            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1190            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1191        ).collect(Collectors.toList());
1192    }
1193
1194    public synchronized void resetFileParams() {
1195        this.fileParams = null;
1196    }
1197
1198    public synchronized void setFileParams(FileParams fileParams) {
1199        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1200            fileParams.sims = this.fileParams.sims;
1201        }
1202        this.fileParams = fileParams;
1203        if (fileParams != null && getSims().isEmpty()) {
1204            addPayload(fileParams.toSims());
1205        }
1206    }
1207
1208    public synchronized FileParams getFileParams() {
1209        if (fileParams == null) {
1210            List<Element> sims = getSims();
1211            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1212            if (this.transferable != null) {
1213                fileParams.size = this.transferable.getFileSize();
1214            }
1215        }
1216
1217        return fileParams;
1218    }
1219
1220    private static int parseInt(String value) {
1221        try {
1222            return Integer.parseInt(value);
1223        } catch (NumberFormatException e) {
1224            return 0;
1225        }
1226    }
1227
1228    public void untie() {
1229        this.mNextMessage = null;
1230        this.mPreviousMessage = null;
1231    }
1232
1233    public boolean isPrivateMessage() {
1234        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1235    }
1236
1237    public boolean isFileOrImage() {
1238        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1239    }
1240
1241
1242    public boolean isTypeText() {
1243        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1244    }
1245
1246    public boolean hasFileOnRemoteHost() {
1247        return isFileOrImage() && getFileParams().url != null;
1248    }
1249
1250    public boolean needsUploading() {
1251        return isFileOrImage() && getFileParams().url == null;
1252    }
1253
1254    public static class FileParams {
1255        public String url;
1256        public Long size = null;
1257        public int width = 0;
1258        public int height = 0;
1259        public int runtime = 0;
1260        public Element sims = null;
1261
1262        public FileParams() { }
1263
1264        public FileParams(Element el) {
1265            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1266                this.url = el.findChildContent("url", Namespace.OOB);
1267            }
1268            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1269                sims = el;
1270                final String refUri = el.getAttribute("uri");
1271                if (refUri != null) url = refUri;
1272                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1273                if (mediaSharing != null) {
1274                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1275                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1276                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1277                    if (file != null) {
1278                        try {
1279                            String sizeS = file.findChildContent("size", file.getNamespace());
1280                            if (sizeS != null) size = new Long(sizeS);
1281                            String widthS = file.findChildContent("width", "https://schema.org/");
1282                            if (widthS != null) width = parseInt(widthS);
1283                            String heightS = file.findChildContent("height", "https://schema.org/");
1284                            if (heightS != null) height = parseInt(heightS);
1285                            String durationS = file.findChildContent("duration", "https://schema.org/");
1286                            if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1287                        } catch (final NumberFormatException e) {
1288                            Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1289                        }
1290                    }
1291
1292                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1293                    if (sources != null) {
1294                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1295                        if (ref != null) url = ref.getAttribute("uri");
1296                    }
1297                }
1298            }
1299        }
1300
1301        public FileParams(String ser) {
1302            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1303            switch (parts.length) {
1304                case 1:
1305                    try {
1306                        this.size = Long.parseLong(parts[0]);
1307                    } catch (final NumberFormatException e) {
1308                        this.url = URL.tryParse(parts[0]);
1309                    }
1310                    break;
1311                case 5:
1312                    this.runtime = parseInt(parts[4]);
1313                case 4:
1314                    this.width = parseInt(parts[2]);
1315                    this.height = parseInt(parts[3]);
1316                case 2:
1317                    this.url = URL.tryParse(parts[0]);
1318                    this.size = Longs.tryParse(parts[1]);
1319                    break;
1320                case 3:
1321                    this.size = Longs.tryParse(parts[0]);
1322                    this.width = parseInt(parts[1]);
1323                    this.height = parseInt(parts[2]);
1324                    break;
1325            }
1326        }
1327
1328        public boolean isEmpty() {
1329            return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1330        }
1331
1332        public long getSize() {
1333            return size == null ? 0 : size;
1334        }
1335
1336        public String getName() {
1337            Element file = getFileElement();
1338            if (file == null) return null;
1339
1340            return file.findChildContent("name", file.getNamespace());
1341        }
1342
1343        public void setName(final String name) {
1344            if (sims == null) toSims();
1345            Element file = getFileElement();
1346
1347            for (Element child : file.getChildren()) {
1348                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1349                    file.removeChild(child);
1350                }
1351            }
1352
1353            if (name != null) {
1354                file.addChild("name", file.getNamespace()).setContent(name);
1355            }
1356        }
1357
1358        public String getMediaType() {
1359            Element file = getFileElement();
1360            if (file == null) return null;
1361
1362            return file.findChildContent("media-type", file.getNamespace());
1363        }
1364
1365        public void setMediaType(final String mime) {
1366            if (sims == null) toSims();
1367            Element file = getFileElement();
1368
1369            for (Element child : file.getChildren()) {
1370                if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1371                    file.removeChild(child);
1372                }
1373            }
1374
1375            if (mime != null) {
1376                file.addChild("media-type", file.getNamespace()).setContent(mime);
1377            }
1378        }
1379
1380        public Element toSims() {
1381            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1382            sims.setAttribute("type", "data");
1383            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1384            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1385
1386            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1387            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1388            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1389            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1390
1391            file.removeChild(file.findChild("size", file.getNamespace()));
1392            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1393
1394            file.removeChild(file.findChild("width", "https://schema.org/"));
1395            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1396
1397            file.removeChild(file.findChild("height", "https://schema.org/"));
1398            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1399
1400            file.removeChild(file.findChild("duration", "https://schema.org/"));
1401            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1402
1403            if (url != null) {
1404                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1405                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1406
1407                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1408                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1409                source.setAttribute("type", "data");
1410                source.setAttribute("uri", url);
1411            }
1412
1413            return sims;
1414        }
1415
1416        protected Element getFileElement() {
1417            Element file = null;
1418            if (sims == null) return file;
1419
1420            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1421            if (mediaSharing == null) return file;
1422            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1423            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1424            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1425            return file;
1426        }
1427
1428        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1429            if (sims == null) toSims();
1430            Element file = getFileElement();
1431
1432            for (Element child : file.getChildren()) {
1433                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1434                    file.removeChild(child);
1435                }
1436            }
1437
1438            for (Cid cid : cids) {
1439                file.addChild("hash", "urn:xmpp:hashes:2")
1440                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1441                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1442            }
1443        }
1444
1445        public List<Cid> getCids() {
1446            List<Cid> cids = new ArrayList<>();
1447            Element file = getFileElement();
1448            if (file == null) return cids;
1449
1450            for (Element child : file.getChildren()) {
1451                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1452                    try {
1453                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1454                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1455                }
1456            }
1457
1458            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1459
1460            return cids;
1461        }
1462
1463        public void addThumbnail(int width, int height, String mimeType, String uri) {
1464            for (Element thumb : getThumbnails()) {
1465                if (uri.equals(thumb.getAttribute("uri"))) return;
1466            }
1467
1468            if (sims == null) toSims();
1469            Element file = getFileElement();
1470            file.addChild(
1471                new Element("thumbnail", "urn:xmpp:thumbs:1")
1472                    .setAttribute("width", Integer.toString(width))
1473                    .setAttribute("height", Integer.toString(height))
1474                    .setAttribute("type", mimeType)
1475                    .setAttribute("uri", uri)
1476            );
1477        }
1478
1479        public List<Element> getThumbnails() {
1480            List<Element> thumbs = new ArrayList<>();
1481            Element file = getFileElement();
1482            if (file == null) return thumbs;
1483
1484            for (Element child : file.getChildren()) {
1485                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1486                    thumbs.add(child);
1487                }
1488            }
1489
1490            return thumbs;
1491        }
1492
1493        public String toString() {
1494            final StringBuilder builder = new StringBuilder();
1495            if (url != null) builder.append(url);
1496            if (size != null) builder.append('|').append(size.toString());
1497            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1498            if (height > 0 || runtime > 0) builder.append('|').append(height);
1499            if (runtime > 0) builder.append('|').append(runtime);
1500            return builder.toString();
1501        }
1502
1503        public boolean equals(Object o) {
1504            if (!(o instanceof FileParams)) return false;
1505            if (url == null) return false;
1506
1507            return url.equals(((FileParams) o).url);
1508        }
1509
1510        public int hashCode() {
1511            return url == null ? super.hashCode() : url.hashCode();
1512        }
1513    }
1514
1515    public void setFingerprint(String fingerprint) {
1516        this.axolotlFingerprint = fingerprint;
1517    }
1518
1519    public String getFingerprint() {
1520        return axolotlFingerprint;
1521    }
1522
1523    public boolean isTrusted() {
1524        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1525        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1526        return s != null && s.isTrusted();
1527    }
1528
1529    private int getPreviousEncryption() {
1530        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1531            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1532                continue;
1533            }
1534            return iterator.getEncryption();
1535        }
1536        return ENCRYPTION_NONE;
1537    }
1538
1539    private int getNextEncryption() {
1540        if (this.conversation instanceof Conversation) {
1541            Conversation conversation = (Conversation) this.conversation;
1542            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1543                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1544                    continue;
1545                }
1546                return iterator.getEncryption();
1547            }
1548            return conversation.getNextEncryption();
1549        } else {
1550            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1551        }
1552    }
1553
1554    public boolean isValidInSession() {
1555        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1556        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1557
1558        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1559                || futureEncryption == ENCRYPTION_NONE
1560                || pastEncryption != futureEncryption;
1561
1562        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1563    }
1564
1565    private static int getCleanedEncryption(int encryption) {
1566        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1567            return ENCRYPTION_PGP;
1568        }
1569        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1570            return ENCRYPTION_AXOLOTL;
1571        }
1572        return encryption;
1573    }
1574
1575    public static boolean configurePrivateMessage(final Message message) {
1576        return configurePrivateMessage(message, false);
1577    }
1578
1579    public static boolean configurePrivateFileMessage(final Message message) {
1580        return configurePrivateMessage(message, true);
1581    }
1582
1583    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1584        final Conversation conversation;
1585        if (message.conversation instanceof Conversation) {
1586            conversation = (Conversation) message.conversation;
1587        } else {
1588            return false;
1589        }
1590        if (conversation.getMode() == Conversation.MODE_MULTI) {
1591            final Jid nextCounterpart = conversation.getNextCounterpart();
1592            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1593        }
1594        return false;
1595    }
1596
1597    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1598        final Conversation conversation;
1599        if (message.conversation instanceof Conversation) {
1600            conversation = (Conversation) message.conversation;
1601        } else {
1602            return false;
1603        }
1604        return configurePrivateMessage(conversation, message, counterpart, false);
1605    }
1606
1607    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1608        if (counterpart == null) {
1609            return false;
1610        }
1611        message.setCounterpart(counterpart);
1612        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1613        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1614        return true;
1615    }
1616}