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();
 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() {
 533        this.payloads.removeAll(getFallbacks());
 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() {
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 boolean isEmpty() {
1322            return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1323        }
1324
1325        public long getSize() {
1326            return size == null ? 0 : size;
1327        }
1328
1329        public String getName() {
1330            Element file = getFileElement();
1331            if (file == null) return null;
1332
1333            return file.findChildContent("name", file.getNamespace());
1334        }
1335
1336        public void setName(final String name) {
1337            if (sims == null) toSims();
1338            Element file = getFileElement();
1339
1340            for (Element child : file.getChildren()) {
1341                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1342                    file.removeChild(child);
1343                }
1344            }
1345
1346            if (name != null) {
1347                file.addChild("name", file.getNamespace()).setContent(name);
1348            }
1349        }
1350
1351        public String getMediaType() {
1352            Element file = getFileElement();
1353            if (file == null) return null;
1354
1355            return file.findChildContent("media-type", file.getNamespace());
1356        }
1357
1358        public void setMediaType(final String mime) {
1359            if (sims == null) toSims();
1360            Element file = getFileElement();
1361
1362            for (Element child : file.getChildren()) {
1363                if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1364                    file.removeChild(child);
1365                }
1366            }
1367
1368            if (mime != null) {
1369                file.addChild("media-type", file.getNamespace()).setContent(mime);
1370            }
1371        }
1372
1373        public Element toSims() {
1374            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1375            sims.setAttribute("type", "data");
1376            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1377            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1378
1379            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1380            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1381            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1382            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1383
1384            file.removeChild(file.findChild("size", file.getNamespace()));
1385            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1386
1387            file.removeChild(file.findChild("width", "https://schema.org/"));
1388            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1389
1390            file.removeChild(file.findChild("height", "https://schema.org/"));
1391            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1392
1393            file.removeChild(file.findChild("duration", "https://schema.org/"));
1394            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1395
1396            if (url != null) {
1397                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1398                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1399
1400                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1401                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1402                source.setAttribute("type", "data");
1403                source.setAttribute("uri", url);
1404            }
1405
1406            return sims;
1407        }
1408
1409        protected Element getFileElement() {
1410            Element file = null;
1411            if (sims == null) return file;
1412
1413            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1414            if (mediaSharing == null) return file;
1415            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1416            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1417            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1418            return file;
1419        }
1420
1421        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1422            if (sims == null) toSims();
1423            Element file = getFileElement();
1424
1425            for (Element child : file.getChildren()) {
1426                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1427                    file.removeChild(child);
1428                }
1429            }
1430
1431            for (Cid cid : cids) {
1432                file.addChild("hash", "urn:xmpp:hashes:2")
1433                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1434                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1435            }
1436        }
1437
1438        public List<Cid> getCids() {
1439            List<Cid> cids = new ArrayList<>();
1440            Element file = getFileElement();
1441            if (file == null) return cids;
1442
1443            for (Element child : file.getChildren()) {
1444                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1445                    try {
1446                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1447                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1448                }
1449            }
1450
1451            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1452
1453            return cids;
1454        }
1455
1456        public List<Element> getThumbnails() {
1457            List<Element> thumbs = new ArrayList<>();
1458            Element file = getFileElement();
1459            if (file == null) return thumbs;
1460
1461            for (Element child : file.getChildren()) {
1462                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1463                    thumbs.add(child);
1464                }
1465            }
1466
1467            return thumbs;
1468        }
1469
1470        public String toString() {
1471            final StringBuilder builder = new StringBuilder();
1472            if (url != null) builder.append(url);
1473            if (size != null) builder.append('|').append(size.toString());
1474            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1475            if (height > 0 || runtime > 0) builder.append('|').append(height);
1476            if (runtime > 0) builder.append('|').append(runtime);
1477            return builder.toString();
1478        }
1479
1480        public boolean equals(Object o) {
1481            if (!(o instanceof FileParams)) return false;
1482            if (url == null) return false;
1483
1484            return url.equals(((FileParams) o).url);
1485        }
1486
1487        public int hashCode() {
1488            return url == null ? super.hashCode() : url.hashCode();
1489        }
1490    }
1491
1492    public void setFingerprint(String fingerprint) {
1493        this.axolotlFingerprint = fingerprint;
1494    }
1495
1496    public String getFingerprint() {
1497        return axolotlFingerprint;
1498    }
1499
1500    public boolean isTrusted() {
1501        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1502        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1503        return s != null && s.isTrusted();
1504    }
1505
1506    private int getPreviousEncryption() {
1507        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1508            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1509                continue;
1510            }
1511            return iterator.getEncryption();
1512        }
1513        return ENCRYPTION_NONE;
1514    }
1515
1516    private int getNextEncryption() {
1517        if (this.conversation instanceof Conversation) {
1518            Conversation conversation = (Conversation) this.conversation;
1519            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1520                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1521                    continue;
1522                }
1523                return iterator.getEncryption();
1524            }
1525            return conversation.getNextEncryption();
1526        } else {
1527            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1528        }
1529    }
1530
1531    public boolean isValidInSession() {
1532        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1533        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1534
1535        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1536                || futureEncryption == ENCRYPTION_NONE
1537                || pastEncryption != futureEncryption;
1538
1539        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1540    }
1541
1542    private static int getCleanedEncryption(int encryption) {
1543        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1544            return ENCRYPTION_PGP;
1545        }
1546        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1547            return ENCRYPTION_AXOLOTL;
1548        }
1549        return encryption;
1550    }
1551
1552    public static boolean configurePrivateMessage(final Message message) {
1553        return configurePrivateMessage(message, false);
1554    }
1555
1556    public static boolean configurePrivateFileMessage(final Message message) {
1557        return configurePrivateMessage(message, true);
1558    }
1559
1560    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1561        final Conversation conversation;
1562        if (message.conversation instanceof Conversation) {
1563            conversation = (Conversation) message.conversation;
1564        } else {
1565            return false;
1566        }
1567        if (conversation.getMode() == Conversation.MODE_MULTI) {
1568            final Jid nextCounterpart = conversation.getNextCounterpart();
1569            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1570        }
1571        return false;
1572    }
1573
1574    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1575        final Conversation conversation;
1576        if (message.conversation instanceof Conversation) {
1577            conversation = (Conversation) message.conversation;
1578        } else {
1579            return false;
1580        }
1581        return configurePrivateMessage(conversation, message, counterpart, false);
1582    }
1583
1584    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1585        if (counterpart == null) {
1586            return false;
1587        }
1588        message.setCounterpart(counterpart);
1589        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1590        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1591        return true;
1592    }
1593}