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.MyLinkify;
  51import eu.siacs.conversations.ui.util.PresenceSelector;
  52import eu.siacs.conversations.ui.util.QuoteHelper;
  53import eu.siacs.conversations.utils.CryptoHelper;
  54import eu.siacs.conversations.utils.Emoticons;
  55import eu.siacs.conversations.utils.GeoHelper;
  56import eu.siacs.conversations.utils.MessageUtils;
  57import eu.siacs.conversations.utils.MimeUtils;
  58import eu.siacs.conversations.utils.StringUtils;
  59import eu.siacs.conversations.utils.UIHelper;
  60import eu.siacs.conversations.xmpp.Jid;
  61import eu.siacs.conversations.xml.Element;
  62import eu.siacs.conversations.xml.Namespace;
  63import eu.siacs.conversations.xml.Tag;
  64import eu.siacs.conversations.xml.XmlReader;
  65
  66public class Message extends AbstractEntity implements AvatarService.Avatarable {
  67
  68    public static final String TABLENAME = "messages";
  69
  70    public static final int STATUS_RECEIVED = 0;
  71    public static final int STATUS_UNSEND = 1;
  72    public static final int STATUS_SEND = 2;
  73    public static final int STATUS_SEND_FAILED = 3;
  74    public static final int STATUS_WAITING = 5;
  75    public static final int STATUS_OFFERED = 6;
  76    public static final int STATUS_SEND_RECEIVED = 7;
  77    public static final int STATUS_SEND_DISPLAYED = 8;
  78
  79    public static final int ENCRYPTION_NONE = 0;
  80    public static final int ENCRYPTION_PGP = 1;
  81    public static final int ENCRYPTION_OTR = 2;
  82    public static final int ENCRYPTION_DECRYPTED = 3;
  83    public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
  84    public static final int ENCRYPTION_AXOLOTL = 5;
  85    public static final int ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE = 6;
  86    public static final int ENCRYPTION_AXOLOTL_FAILED = 7;
  87
  88    public static final int TYPE_TEXT = 0;
  89    public static final int TYPE_IMAGE = 1;
  90    public static final int TYPE_FILE = 2;
  91    public static final int TYPE_STATUS = 3;
  92    public static final int TYPE_PRIVATE = 4;
  93    public static final int TYPE_PRIVATE_FILE = 5;
  94    public static final int TYPE_RTP_SESSION = 6;
  95
  96    public static final String CONVERSATION = "conversationUuid";
  97    public static final String COUNTERPART = "counterpart";
  98    public static final String TRUE_COUNTERPART = "trueCounterpart";
  99    public static final String BODY = "body";
 100    public static final String BODY_LANGUAGE = "bodyLanguage";
 101    public static final String TIME_SENT = "timeSent";
 102    public static final String ENCRYPTION = "encryption";
 103    public static final String STATUS = "status";
 104    public static final String TYPE = "type";
 105    public static final String CARBON = "carbon";
 106    public static final String OOB = "oob";
 107    public static final String EDITED = "edited";
 108    public static final String REMOTE_MSG_ID = "remoteMsgId";
 109    public static final String SERVER_MSG_ID = "serverMsgId";
 110    public static final String RELATIVE_FILE_PATH = "relativeFilePath";
 111    public static final String FINGERPRINT = "axolotl_fingerprint";
 112    public static final String READ = "read";
 113    public static final String ERROR_MESSAGE = "errorMsg";
 114    public static final String READ_BY_MARKERS = "readByMarkers";
 115    public static final String MARKABLE = "markable";
 116    public static final String DELETED = "deleted";
 117    public static final String ME_COMMAND = "/me ";
 118
 119    public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
 120
 121
 122    public boolean markable = false;
 123    protected String conversationUuid;
 124    protected Jid counterpart;
 125    protected Jid trueCounterpart;
 126    protected String body;
 127    protected String subject;
 128    protected String encryptedBody;
 129    protected long timeSent;
 130    protected long timeReceived;
 131    protected int encryption;
 132    protected int status;
 133    protected int type;
 134    protected boolean deleted = false;
 135    protected boolean carbon = false;
 136    private boolean oob = false;
 137    protected List<Element> payloads = new ArrayList<>();
 138    protected List<Edit> edits = new ArrayList<>();
 139    protected String relativeFilePath;
 140    protected boolean read = true;
 141    protected String remoteMsgId = null;
 142    private String bodyLanguage = null;
 143    protected String serverMsgId = null;
 144    private final Conversational conversation;
 145    protected Transferable transferable = null;
 146    private Message mNextMessage = null;
 147    private Message mPreviousMessage = null;
 148    private String axolotlFingerprint = null;
 149    private String errorMessage = null;
 150    private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
 151
 152    private Boolean isGeoUri = null;
 153    private Boolean isEmojisOnly = null;
 154    private Boolean treatAsDownloadable = null;
 155    private FileParams fileParams = null;
 156    private List<MucOptions.User> counterparts;
 157    private WeakReference<MucOptions.User> user;
 158
 159    protected Message(Conversational conversation) {
 160        this.conversation = conversation;
 161    }
 162
 163    public Message(Conversational conversation, String body, int encryption) {
 164        this(conversation, body, encryption, STATUS_UNSEND);
 165    }
 166
 167    public Message(Conversational conversation, String body, int encryption, int status) {
 168        this(conversation, java.util.UUID.randomUUID().toString(),
 169                conversation.getUuid(),
 170                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 171                null,
 172                body,
 173                System.currentTimeMillis(),
 174                encryption,
 175                status,
 176                TYPE_TEXT,
 177                false,
 178                null,
 179                null,
 180                null,
 181                null,
 182                true,
 183                null,
 184                false,
 185                null,
 186                null,
 187                false,
 188                false,
 189                null,
 190                System.currentTimeMillis(),
 191                null,
 192                null,
 193                null);
 194    }
 195
 196    public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
 197        this(conversation, java.util.UUID.randomUUID().toString(),
 198                conversation.getUuid(),
 199                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 200                null,
 201                null,
 202                System.currentTimeMillis(),
 203                Message.ENCRYPTION_NONE,
 204                status,
 205                type,
 206                false,
 207                remoteMsgId,
 208                null,
 209                null,
 210                null,
 211                true,
 212                null,
 213                false,
 214                null,
 215                null,
 216                false,
 217                false,
 218                null,
 219                System.currentTimeMillis(),
 220                null,
 221                null,
 222                null);
 223    }
 224
 225    protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
 226                      final Jid trueCounterpart, final String body, final long timeSent,
 227                      final int encryption, final int status, final int type, final boolean carbon,
 228                      final String remoteMsgId, final String relativeFilePath,
 229                      final String serverMsgId, final String fingerprint, final boolean read,
 230                      final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
 231                      final boolean markable, final boolean deleted, final String bodyLanguage, final long timeReceived, final String subject, final String fileParams, final List<Element> payloads) {
 232        this.conversation = conversation;
 233        this.uuid = uuid;
 234        this.conversationUuid = conversationUUid;
 235        this.counterpart = counterpart;
 236        this.trueCounterpart = trueCounterpart;
 237        this.body = body == null ? "" : body;
 238        this.timeSent = timeSent;
 239        this.encryption = encryption;
 240        this.status = status;
 241        this.type = type;
 242        this.carbon = carbon;
 243        this.remoteMsgId = remoteMsgId;
 244        this.relativeFilePath = relativeFilePath;
 245        this.serverMsgId = serverMsgId;
 246        this.axolotlFingerprint = fingerprint;
 247        this.read = read;
 248        this.edits = Edit.fromJson(edited);
 249        this.oob = oob;
 250        this.errorMessage = errorMessage;
 251        this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
 252        this.markable = markable;
 253        this.deleted = deleted;
 254        this.bodyLanguage = bodyLanguage;
 255        this.timeReceived = timeReceived;
 256        this.subject = subject;
 257        if (payloads != null) this.payloads = payloads;
 258        if (fileParams != null && getSims().isEmpty()) this.fileParams = new FileParams(fileParams);
 259    }
 260
 261    public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
 262        String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
 263        List<Element> payloads = new ArrayList<>();
 264        if (payloadsStr != null) {
 265            final XmlReader xmlReader = new XmlReader();
 266            xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
 267            Tag tag;
 268            while ((tag = xmlReader.readTag()) != null) {
 269                payloads.add(xmlReader.readElement(tag));
 270            }
 271        }
 272
 273        return new Message(conversation,
 274                cursor.getString(cursor.getColumnIndex(UUID)),
 275                cursor.getString(cursor.getColumnIndex(CONVERSATION)),
 276                fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
 277                fromString(cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART))),
 278                cursor.getString(cursor.getColumnIndex(BODY)),
 279                cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
 280                cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
 281                cursor.getInt(cursor.getColumnIndex(STATUS)),
 282                cursor.getInt(cursor.getColumnIndex(TYPE)),
 283                cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
 284                cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
 285                cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
 286                cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
 287                cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
 288                cursor.getInt(cursor.getColumnIndex(READ)) > 0,
 289                cursor.getString(cursor.getColumnIndex(EDITED)),
 290                cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
 291                cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
 292                ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))),
 293                cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
 294                cursor.getInt(cursor.getColumnIndex(DELETED)) > 0,
 295                cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
 296                cursor.getLong(cursor.getColumnIndex(cursor.isNull(cursor.getColumnIndex("timeReceived")) ? TIME_SENT : "timeReceived")),
 297                cursor.getString(cursor.getColumnIndex("subject")),
 298                cursor.getString(cursor.getColumnIndex("fileParams")),
 299                payloads
 300        );
 301    }
 302
 303    private static Jid fromString(String value) {
 304        try {
 305            if (value != null) {
 306                return Jid.of(value);
 307            }
 308        } catch (IllegalArgumentException e) {
 309            return null;
 310        }
 311        return null;
 312    }
 313
 314    public static Message createStatusMessage(Conversation conversation, String body) {
 315        final Message message = new Message(conversation);
 316        message.setType(Message.TYPE_STATUS);
 317        message.setStatus(Message.STATUS_RECEIVED);
 318        message.body = body;
 319        return message;
 320    }
 321
 322    public static Message createLoadMoreMessage(Conversation conversation) {
 323        final Message message = new Message(conversation);
 324        message.setType(Message.TYPE_STATUS);
 325        message.body = "LOAD_MORE";
 326        return message;
 327    }
 328
 329    public ContentValues getCheogramContentValues() {
 330        ContentValues values = new ContentValues();
 331        values.put(UUID, uuid);
 332        values.put("subject", subject);
 333        values.put("fileParams", fileParams == null ? null : fileParams.toString());
 334        if (fileParams != null && !fileParams.isEmpty()) {
 335            List<Element> sims = getSims();
 336            if (sims.isEmpty()) {
 337                addPayload(fileParams.toSims());
 338            } else {
 339                sims.get(0).replaceChildren(fileParams.toSims().getChildren());
 340            }
 341        }
 342        values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
 343        return values;
 344    }
 345
 346    @Override
 347    public ContentValues getContentValues() {
 348        ContentValues values = new ContentValues();
 349        values.put(UUID, uuid);
 350        values.put(CONVERSATION, conversationUuid);
 351        if (counterpart == null) {
 352            values.putNull(COUNTERPART);
 353        } else {
 354            values.put(COUNTERPART, counterpart.toString());
 355        }
 356        if (trueCounterpart == null) {
 357            values.putNull(TRUE_COUNTERPART);
 358        } else {
 359            values.put(TRUE_COUNTERPART, trueCounterpart.toString());
 360        }
 361        values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
 362        values.put(TIME_SENT, timeSent);
 363        values.put(ENCRYPTION, encryption);
 364        values.put(STATUS, status);
 365        values.put(TYPE, type);
 366        values.put(CARBON, carbon ? 1 : 0);
 367        values.put(REMOTE_MSG_ID, remoteMsgId);
 368        values.put(RELATIVE_FILE_PATH, relativeFilePath);
 369        values.put(SERVER_MSG_ID, serverMsgId);
 370        values.put(FINGERPRINT, axolotlFingerprint);
 371        values.put(READ, read ? 1 : 0);
 372        try {
 373            values.put(EDITED, Edit.toJson(edits));
 374        } catch (JSONException e) {
 375            Log.e(Config.LOGTAG, "error persisting json for edits", e);
 376        }
 377        values.put(OOB, oob ? 1 : 0);
 378        values.put(ERROR_MESSAGE, errorMessage);
 379        values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
 380        values.put(MARKABLE, markable ? 1 : 0);
 381        values.put(DELETED, deleted ? 1 : 0);
 382        values.put(BODY_LANGUAGE, bodyLanguage);
 383        return values;
 384    }
 385
 386    public String replyId() {
 387        return conversation.getMode() == Conversation.MODE_MULTI ? getServerMsgId() : getRemoteMsgId();
 388	 }
 389
 390    public Message reply() {
 391        Message m = new Message(conversation, QuoteHelper.quote(MessageUtils.prepareQuote(this)) + "\n", ENCRYPTION_NONE);
 392        m.setThread(getThread());
 393        m.addPayload(
 394            new Element("reply", "urn:xmpp:reply:0")
 395                .setAttribute("to", getCounterpart())
 396                .setAttribute("id", replyId())
 397        );
 398        final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0");
 399        fallback.addChild("body", "urn:xmpp:fallback:0")
 400                .setAttribute("start", "0")
 401                .setAttribute("end", "" + m.body.codePointCount(0, m.body.length()));
 402        m.addPayload(fallback);
 403        return m;
 404    }
 405
 406    public Message react(String emoji) {
 407        Set<String> emojis = new HashSet<>();
 408        if (conversation instanceof Conversation) emojis = ((Conversation) conversation).findReactionsTo(replyId(), null);
 409        emojis.add(emoji);
 410        final Message m = reply();
 411        m.appendBody(emoji);
 412        final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
 413        fallback.addChild("body", "urn:xmpp:fallback:0");
 414        m.addPayload(fallback);
 415        final Element reactions = new Element("reactions", "urn:xmpp:reactions:0").setAttribute("id", replyId());
 416        for (String oneEmoji : emojis) {
 417            reactions.addChild("reaction", "urn:xmpp:reactions:0").setContent(oneEmoji);
 418        }
 419        m.addPayload(reactions);
 420        return m;
 421    }
 422
 423    public void setReactions(Element reactions) {
 424        if (this.payloads != null) {
 425            this.payloads.remove(getReactions());
 426        }
 427        addPayload(reactions);
 428    }
 429
 430    public Element getReactions() {
 431        if (this.payloads == null) return null;
 432
 433        for (Element el : this.payloads) {
 434            if (el.getName().equals("reactions") && el.getNamespace().equals("urn:xmpp:reactions:0")) {
 435                return el;
 436            }
 437        }
 438
 439        return null;
 440    }
 441
 442    public Element getReply() {
 443        if (this.payloads == null) return null;
 444
 445        for (Element el : this.payloads) {
 446            if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) {
 447                return el;
 448            }
 449        }
 450
 451        return null;
 452    }
 453
 454    public boolean isAttention() {
 455        if (this.payloads == null) return false;
 456
 457        for (Element el : this.payloads) {
 458            if (el.getName().equals("attention") && el.getNamespace().equals("urn:xmpp:attention:0")) {
 459                return true;
 460            }
 461        }
 462
 463        return false;
 464    }
 465
 466    public String getConversationUuid() {
 467        return conversationUuid;
 468    }
 469
 470    public Conversational getConversation() {
 471        return this.conversation;
 472    }
 473
 474    public Jid getCounterpart() {
 475        return counterpart;
 476    }
 477
 478    public void setCounterpart(final Jid counterpart) {
 479        this.counterpart = counterpart;
 480    }
 481
 482    public Contact getContact() {
 483        if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
 484            if (this.trueCounterpart != null) {
 485                return this.conversation.getAccount().getRoster()
 486                           .getContact(this.trueCounterpart);
 487            }
 488
 489            return this.conversation.getContact();
 490        } else {
 491            if (this.trueCounterpart == null) {
 492                return null;
 493            } else {
 494                return this.conversation.getAccount().getRoster()
 495                        .getContactFromContactList(this.trueCounterpart);
 496            }
 497        }
 498    }
 499
 500    public String getQuoteableBody() {
 501        return this.body;
 502    }
 503
 504    public String getBody() {
 505        StringBuilder body = new StringBuilder(this.body);
 506
 507        List<Element> fallbacks = getFallbacks("http://jabber.org/protocol/address", Namespace.OOB);
 508        List<Pair<Integer, Integer>> spans = new ArrayList<>();
 509        for (Element fallback : fallbacks) {
 510            for (Element span : fallback.getChildren()) {
 511                if (!span.getName().equals("body") && !span.getNamespace().equals("urn:xmpp:fallback:0")) continue;
 512                if (span.getAttribute("start") == null || span.getAttribute("end") == null) return "";
 513                spans.add(new Pair(parseInt(span.getAttribute("start")), parseInt(span.getAttribute("end"))));
 514            }
 515        }
 516        // Do them in reverse order so that span deletions don't affect the indexes of other spans
 517        spans.sort((x, y) -> y.first.compareTo(x.first));
 518        try {
 519            for (Pair<Integer, Integer> span : spans) {
 520                body.delete(body.offsetByCodePoints(0, span.first.intValue()), body.offsetByCodePoints(0, span.second.intValue()));
 521            }
 522        } catch (final IndexOutOfBoundsException e) { spans.clear(); }
 523
 524        if (spans.isEmpty() && getOob() != null) {
 525            return body.toString().replace(getOob().toString(), "");
 526        } else if (spans.isEmpty() && isGeoUri()) {
 527            return "";
 528        } else {
 529            return body.toString();
 530        }
 531    }
 532
 533    public synchronized void clearFallbacks(String... includeFor) {
 534        this.payloads.removeAll(getFallbacks(includeFor));
 535    }
 536
 537    public synchronized void setBody(String body) {
 538        this.body = body;
 539        this.isGeoUri = null;
 540        this.isEmojisOnly = null;
 541        this.treatAsDownloadable = null;
 542    }
 543
 544    public synchronized void appendBody(String append) {
 545        this.body += append;
 546        this.isGeoUri = null;
 547        this.isEmojisOnly = null;
 548        this.treatAsDownloadable = null;
 549    }
 550
 551    public String getSubject() {
 552        return subject;
 553    }
 554
 555    public synchronized void setSubject(String subject) {
 556        this.subject = subject;
 557    }
 558
 559    public Element getThread() {
 560        if (this.payloads == null) return null;
 561
 562        for (Element el : this.payloads) {
 563            if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
 564                return el;
 565            }
 566        }
 567
 568        return null;
 569    }
 570
 571    public void setThread(Element thread) {
 572        payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
 573        addPayload(thread);
 574    }
 575
 576    public void setMucUser(MucOptions.User user) {
 577        this.user = new WeakReference<>(user);
 578    }
 579
 580    public boolean sameMucUser(Message otherMessage) {
 581        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 582        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 583        return thisUser != null && thisUser == otherUser;
 584    }
 585
 586    public String getErrorMessage() {
 587        return errorMessage;
 588    }
 589
 590    public boolean setErrorMessage(String message) {
 591        boolean changed = (message != null && !message.equals(errorMessage))
 592                || (message == null && errorMessage != null);
 593        this.errorMessage = message;
 594        return changed;
 595    }
 596
 597    public long getTimeReceived() {
 598        return timeReceived;
 599    }
 600
 601    public long getTimeSent() {
 602        return timeSent;
 603    }
 604
 605    public int getEncryption() {
 606        return encryption;
 607    }
 608
 609    public void setEncryption(int encryption) {
 610        this.encryption = encryption;
 611    }
 612
 613    public int getStatus() {
 614        return status;
 615    }
 616
 617    public void setStatus(int status) {
 618        this.status = status;
 619    }
 620
 621    public String getRelativeFilePath() {
 622        return this.relativeFilePath;
 623    }
 624
 625    public void setRelativeFilePath(String path) {
 626        this.relativeFilePath = path;
 627    }
 628
 629    public String getRemoteMsgId() {
 630        return this.remoteMsgId;
 631    }
 632
 633    public void setRemoteMsgId(String id) {
 634        this.remoteMsgId = id;
 635    }
 636
 637    public String getServerMsgId() {
 638        return this.serverMsgId;
 639    }
 640
 641    public void setServerMsgId(String id) {
 642        this.serverMsgId = id;
 643    }
 644
 645    public boolean isRead() {
 646        return this.read;
 647    }
 648
 649    public boolean isDeleted() {
 650        return this.deleted;
 651    }
 652
 653    public Element getModerated() {
 654        if (this.payloads == null) return null;
 655
 656        for (Element el : this.payloads) {
 657            if (el.getName().equals("moderated") && el.getNamespace().equals("urn:xmpp:message-moderate:0")) {
 658                return el;
 659            }
 660        }
 661
 662        return null;
 663    }
 664
 665    public void setDeleted(boolean deleted) {
 666        this.deleted = deleted;
 667    }
 668
 669    public void markRead() {
 670        this.read = true;
 671    }
 672
 673    public void markUnread() {
 674        this.read = false;
 675    }
 676
 677    public void setTime(long time) {
 678        this.timeSent = time;
 679    }
 680
 681    public void setTimeReceived(long time) {
 682        this.timeReceived = time;
 683    }
 684
 685    public String getEncryptedBody() {
 686        return this.encryptedBody;
 687    }
 688
 689    public void setEncryptedBody(String body) {
 690        this.encryptedBody = body;
 691    }
 692
 693    public int getType() {
 694        return this.type;
 695    }
 696
 697    public void setType(int type) {
 698        this.type = type;
 699    }
 700
 701    public boolean isCarbon() {
 702        return carbon;
 703    }
 704
 705    public void setCarbon(boolean carbon) {
 706        this.carbon = carbon;
 707    }
 708
 709    public void putEdited(String edited, String serverMsgId) {
 710        final Edit edit = new Edit(edited, serverMsgId);
 711        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 712            this.edits.add(edit);
 713        }
 714    }
 715
 716    boolean remoteMsgIdMatchInEdit(String id) {
 717        for (Edit edit : this.edits) {
 718            if (id.equals(edit.getEditedId())) {
 719                return true;
 720            }
 721        }
 722        return false;
 723    }
 724
 725    public String getBodyLanguage() {
 726        return this.bodyLanguage;
 727    }
 728
 729    public void setBodyLanguage(String language) {
 730        this.bodyLanguage = language;
 731    }
 732
 733    public boolean edited() {
 734        return this.edits.size() > 0;
 735    }
 736
 737    public void setTrueCounterpart(Jid trueCounterpart) {
 738        this.trueCounterpart = trueCounterpart;
 739    }
 740
 741    public Jid getTrueCounterpart() {
 742        return this.trueCounterpart;
 743    }
 744
 745    public Transferable getTransferable() {
 746        return this.transferable;
 747    }
 748
 749    public synchronized void setTransferable(Transferable transferable) {
 750        this.transferable = transferable;
 751    }
 752
 753    public boolean addReadByMarker(ReadByMarker readByMarker) {
 754        if (readByMarker.getRealJid() != null) {
 755            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 756                return false;
 757            }
 758        } else if (readByMarker.getFullJid() != null) {
 759            if (readByMarker.getFullJid().equals(counterpart)) {
 760                return false;
 761            }
 762        }
 763        if (this.readByMarkers.add(readByMarker)) {
 764            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 765                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 766                while (iterator.hasNext()) {
 767                    ReadByMarker marker = iterator.next();
 768                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 769                        iterator.remove();
 770                    }
 771                }
 772            }
 773            return true;
 774        } else {
 775            return false;
 776        }
 777    }
 778
 779    public Set<ReadByMarker> getReadByMarkers() {
 780        return ImmutableSet.copyOf(this.readByMarkers);
 781    }
 782
 783    boolean similar(Message message) {
 784        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 785            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 786        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 787            return true;
 788        } else if (this.body == null || this.counterpart == null) {
 789            return false;
 790        } else {
 791            String body, otherBody;
 792            if (this.hasFileOnRemoteHost() && (this.body == null || "".equals(this.body))) {
 793                body = getFileParams().url;
 794                otherBody = message.body == null ? null : message.body.trim();
 795            } else {
 796                body = this.body;
 797                otherBody = message.body;
 798            }
 799            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 800            if (message.getRemoteMsgId() != null) {
 801                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 802                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 803                    return true;
 804                }
 805                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 806                        && matchingCounterpart
 807                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 808            } else {
 809                return this.remoteMsgId == null
 810                        && matchingCounterpart
 811                        && body.equals(otherBody)
 812                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 813            }
 814        }
 815    }
 816
 817    public Message next() {
 818        if (this.conversation instanceof Conversation) {
 819            final Conversation conversation = (Conversation) this.conversation;
 820            synchronized (conversation.messages) {
 821                if (this.mNextMessage == null) {
 822                    int index = conversation.messages.indexOf(this);
 823                    if (index < 0 || index >= conversation.messages.size() - 1) {
 824                        this.mNextMessage = null;
 825                    } else {
 826                        this.mNextMessage = conversation.messages.get(index + 1);
 827                    }
 828                }
 829                return this.mNextMessage;
 830            }
 831        } else {
 832            throw new AssertionError("Calling next should be disabled for stubs");
 833        }
 834    }
 835
 836    public Message prev() {
 837        if (this.conversation instanceof Conversation) {
 838            final Conversation conversation = (Conversation) this.conversation;
 839            synchronized (conversation.messages) {
 840                if (this.mPreviousMessage == null) {
 841                    int index = conversation.messages.indexOf(this);
 842                    if (index <= 0 || index > conversation.messages.size()) {
 843                        this.mPreviousMessage = null;
 844                    } else {
 845                        this.mPreviousMessage = conversation.messages.get(index - 1);
 846                    }
 847                }
 848            }
 849            return this.mPreviousMessage;
 850        } else {
 851            throw new AssertionError("Calling prev should be disabled for stubs");
 852        }
 853    }
 854
 855    public boolean isLastCorrectableMessage() {
 856        Message next = next();
 857        while (next != null) {
 858            if (next.isEditable()) {
 859                return false;
 860            }
 861            next = next.next();
 862        }
 863        return isEditable();
 864    }
 865
 866    public boolean isEditable() {
 867        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 868    }
 869
 870    public boolean mergeable(final Message message) {
 871        return message != null &&
 872                (message.getType() == Message.TYPE_TEXT &&
 873                        this.getTransferable() == null &&
 874                        message.getTransferable() == null &&
 875                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 876                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 877                        this.getType() == message.getType() &&
 878                        this.getSubject() != null &&
 879                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 880                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 881                        this.getCounterpart() != null &&
 882                        this.getCounterpart().equals(message.getCounterpart()) &&
 883                        this.edited() == message.edited() &&
 884                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 885                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 886                        !message.isGeoUri() &&
 887                        !this.isGeoUri() &&
 888                        !message.isOOb() &&
 889                        !this.isOOb() &&
 890                        !message.treatAsDownloadable() &&
 891                        !this.treatAsDownloadable() &&
 892                        !message.hasMeCommand() &&
 893                        !this.hasMeCommand() &&
 894                        !this.bodyIsOnlyEmojis() &&
 895                        !message.bodyIsOnlyEmojis() &&
 896                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 897                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 898                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 899                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 900                );
 901    }
 902
 903    private static boolean isStatusMergeable(int a, int b) {
 904        return a == b || (
 905                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 906                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 907                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 908                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 909                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 910        );
 911    }
 912
 913    private static boolean isEncryptionMergeable(final int a, final int b) {
 914        return a == b
 915                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 916                        .contains(a);
 917    }
 918
 919    public void setCounterparts(List<MucOptions.User> counterparts) {
 920        this.counterparts = counterparts;
 921    }
 922
 923    public List<MucOptions.User> getCounterparts() {
 924        return this.counterparts;
 925    }
 926
 927    @Override
 928    public int getAvatarBackgroundColor() {
 929        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 930            return Color.TRANSPARENT;
 931        } else {
 932            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 933        }
 934    }
 935
 936    @Override
 937    public String getAvatarName() {
 938        return UIHelper.getMessageDisplayName(this);
 939    }
 940
 941    public boolean isOOb() {
 942        return oob || getFileParams().url != null;
 943    }
 944
 945    public static class MergeSeparator {
 946    }
 947
 948    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 949        final Element html = getHtml();
 950        if (html == null || Build.VERSION.SDK_INT < 24) {
 951            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
 952        } else {
 953            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
 954                MessageUtils.filterLtrRtl(html.toString()).trim(),
 955                Html.FROM_HTML_MODE_COMPACT,
 956                (source) -> {
 957                   try {
 958                       if (thumbnailer == null) return fallbackImg;
 959                       Cid cid = BobTransfer.cid(new URI(source));
 960                       if (cid == null) return fallbackImg;
 961                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
 962                       if (thumbnail == null) return fallbackImg;
 963                       return thumbnail;
 964                   } catch (final URISyntaxException e) {
 965                       return fallbackImg;
 966                   }
 967                },
 968                (opening, tag, output, xmlReader) -> {}
 969            ));
 970
 971            // Make images clickable and long-clickable with BetterLinkMovementMethod
 972            ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
 973            for (ImageSpan span : imageSpans) {
 974                final int start = spannable.getSpanStart(span);
 975                final int end = spannable.getSpanEnd(span);
 976
 977                ClickableSpan click_span = new ClickableSpan() {
 978                    @Override
 979                    public void onClick(View widget) { }
 980                };
 981
 982                spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
 983            }
 984
 985            // https://stackoverflow.com/a/10187511/8611
 986            int i = spannable.length();
 987            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
 988            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
 989        }
 990    }
 991
 992    public SpannableStringBuilder getMergedBody() {
 993        return getMergedBody(null, null);
 994    }
 995
 996    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 997        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
 998        Message current = this;
 999        while (current.mergeable(current.next())) {
1000            current = current.next();
1001            if (current == null) {
1002                break;
1003            }
1004            body.append("\n\n");
1005            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
1006                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
1007            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
1008        }
1009        return body;
1010    }
1011
1012    public boolean hasMeCommand() {
1013        return this.body.trim().startsWith(ME_COMMAND);
1014    }
1015
1016    public int getMergedStatus() {
1017        int status = this.status;
1018        Message current = this;
1019        while (current.mergeable(current.next())) {
1020            current = current.next();
1021            if (current == null) {
1022                break;
1023            }
1024            status = current.status;
1025        }
1026        return status;
1027    }
1028
1029    public long getMergedTimeSent() {
1030        long time = this.timeSent;
1031        Message current = this;
1032        while (current.mergeable(current.next())) {
1033            current = current.next();
1034            if (current == null) {
1035                break;
1036            }
1037            time = current.timeSent;
1038        }
1039        return time;
1040    }
1041
1042    public boolean wasMergedIntoPrevious() {
1043        Message prev = this.prev();
1044        return prev != null && prev.mergeable(this);
1045    }
1046
1047    public boolean trusted() {
1048        Contact contact = this.getContact();
1049        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
1050    }
1051
1052    public boolean fixCounterpart() {
1053        final Presences presences = conversation.getContact().getPresences();
1054        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
1055            return true;
1056        } else if (presences.size() >= 1) {
1057            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
1058            return true;
1059        } else {
1060            counterpart = null;
1061            return false;
1062        }
1063    }
1064
1065    public void setUuid(String uuid) {
1066        this.uuid = uuid;
1067    }
1068
1069    public String getEditedId() {
1070        if (edits.size() > 0) {
1071            return edits.get(edits.size() - 1).getEditedId();
1072        } else {
1073            throw new IllegalStateException("Attempting to store unedited message");
1074        }
1075    }
1076
1077    public String getEditedIdWireFormat() {
1078        if (edits.size() > 0) {
1079            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
1080        } else {
1081            throw new IllegalStateException("Attempting to store unedited message");
1082        }
1083    }
1084
1085    public List<URI> getLinks() {
1086        return MyLinkify.extractLinks(new SpannableStringBuilder(getBody())).stream().map((url) -> {
1087            try {
1088                return new URI(url);
1089            } catch (final URISyntaxException e) {
1090                return null;
1091            }
1092        }).filter(x -> x != null).collect(Collectors.toList());
1093    }
1094
1095    public URI getOob() {
1096        final String url = getFileParams().url;
1097        try {
1098            return url == null ? null : new URI(url);
1099        } catch (final URISyntaxException e) {
1100            return null;
1101        }
1102    }
1103
1104    public void clearPayloads() {
1105        this.payloads.clear();
1106    }
1107
1108    public void addPayload(Element el) {
1109        if (el == null) return;
1110
1111        this.payloads.add(el);
1112    }
1113
1114    public List<Element> getPayloads() {
1115       return new ArrayList<>(this.payloads);
1116    }
1117
1118    public List<Element> getFallbacks(String... includeFor) {
1119        List<Element> fallbacks = new ArrayList<>();
1120
1121        if (this.payloads == null) return fallbacks;
1122
1123        for (Element el : this.payloads) {
1124            if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1125                final String fallbackFor = el.getAttribute("for");
1126                if (fallbackFor == null) continue;
1127                for (String includeOne : includeFor) {
1128                    if (fallbackFor.equals(includeOne)) {
1129                        fallbacks.add(el);
1130                        break;
1131                    }
1132                }
1133            }
1134        }
1135
1136        return fallbacks;
1137    }
1138
1139    public Element getHtml() {
1140        if (this.payloads == null) return null;
1141
1142        for (Element el : this.payloads) {
1143            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1144                return el.getChildren().get(0);
1145            }
1146        }
1147
1148        return null;
1149   }
1150
1151    public List<Element> getCommands() {
1152        if (this.payloads == null) return null;
1153
1154        for (Element el : this.payloads) {
1155            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1156                return el.getChildren();
1157            }
1158        }
1159
1160        return null;
1161    }
1162
1163    public String getMimeType() {
1164        String extension;
1165        if (relativeFilePath != null) {
1166            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1167        } else {
1168            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1169            if (url == null) {
1170                return null;
1171            }
1172            extension = MimeUtils.extractRelevantExtension(url);
1173        }
1174        return MimeUtils.guessMimeTypeFromExtension(extension);
1175    }
1176
1177    public synchronized boolean treatAsDownloadable() {
1178        if (treatAsDownloadable == null) {
1179            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1180        }
1181        return treatAsDownloadable;
1182    }
1183
1184    public synchronized boolean bodyIsOnlyEmojis() {
1185        if (isEmojisOnly == null) {
1186            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1187        }
1188        return isEmojisOnly;
1189    }
1190
1191    public synchronized boolean isGeoUri() {
1192        if (isGeoUri == null) {
1193            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1194        }
1195        return isGeoUri;
1196    }
1197
1198    protected List<Element> getSims() {
1199        return payloads.stream().filter(el ->
1200            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1201            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1202        ).collect(Collectors.toList());
1203    }
1204
1205    public synchronized void resetFileParams() {
1206        this.fileParams = null;
1207    }
1208
1209    public synchronized void setFileParams(FileParams fileParams) {
1210        if (fileParams != null && this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1211            fileParams.sims = this.fileParams.sims;
1212        }
1213        this.fileParams = fileParams;
1214        if (fileParams != null && getSims().isEmpty()) {
1215            addPayload(fileParams.toSims());
1216        }
1217    }
1218
1219    public synchronized FileParams getFileParams() {
1220        if (fileParams == null) {
1221            List<Element> sims = getSims();
1222            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1223            if (this.transferable != null) {
1224                fileParams.size = this.transferable.getFileSize();
1225            }
1226        }
1227
1228        return fileParams;
1229    }
1230
1231    private static int parseInt(String value) {
1232        try {
1233            return Integer.parseInt(value);
1234        } catch (NumberFormatException e) {
1235            return 0;
1236        }
1237    }
1238
1239    public void untie() {
1240        this.mNextMessage = null;
1241        this.mPreviousMessage = null;
1242    }
1243
1244    public boolean isPrivateMessage() {
1245        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1246    }
1247
1248    public boolean isFileOrImage() {
1249        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1250    }
1251
1252
1253    public boolean isTypeText() {
1254        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1255    }
1256
1257    public boolean hasFileOnRemoteHost() {
1258        return isFileOrImage() && getFileParams().url != null;
1259    }
1260
1261    public boolean needsUploading() {
1262        return isFileOrImage() && getFileParams().url == null;
1263    }
1264
1265    public static class FileParams {
1266        public String url;
1267        public Long size = null;
1268        public int width = 0;
1269        public int height = 0;
1270        public int runtime = 0;
1271        public Element sims = null;
1272
1273        public FileParams() { }
1274
1275        public FileParams(Element el) {
1276            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1277                this.url = el.findChildContent("url", Namespace.OOB);
1278            }
1279            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1280                sims = el;
1281                final String refUri = el.getAttribute("uri");
1282                if (refUri != null) url = refUri;
1283                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1284                if (mediaSharing != null) {
1285                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1286                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1287                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1288                    if (file != null) {
1289                        try {
1290                            String sizeS = file.findChildContent("size", file.getNamespace());
1291                            if (sizeS != null) size = new Long(sizeS);
1292                            String widthS = file.findChildContent("width", "https://schema.org/");
1293                            if (widthS != null) width = parseInt(widthS);
1294                            String heightS = file.findChildContent("height", "https://schema.org/");
1295                            if (heightS != null) height = parseInt(heightS);
1296                            String durationS = file.findChildContent("duration", "https://schema.org/");
1297                            if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1298                        } catch (final NumberFormatException e) {
1299                            Log.w(Config.LOGTAG, "Trouble parsing as number: " + e);
1300                        }
1301                    }
1302
1303                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1304                    if (sources != null) {
1305                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1306                        if (ref != null) url = ref.getAttribute("uri");
1307                    }
1308                }
1309            }
1310        }
1311
1312        public FileParams(String ser) {
1313            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1314            switch (parts.length) {
1315                case 1:
1316                    try {
1317                        this.size = Long.parseLong(parts[0]);
1318                    } catch (final NumberFormatException e) {
1319                        this.url = URL.tryParse(parts[0]);
1320                    }
1321                    break;
1322                case 5:
1323                    this.runtime = parseInt(parts[4]);
1324                case 4:
1325                    this.width = parseInt(parts[2]);
1326                    this.height = parseInt(parts[3]);
1327                case 2:
1328                    this.url = URL.tryParse(parts[0]);
1329                    this.size = Longs.tryParse(parts[1]);
1330                    break;
1331                case 3:
1332                    this.size = Longs.tryParse(parts[0]);
1333                    this.width = parseInt(parts[1]);
1334                    this.height = parseInt(parts[2]);
1335                    break;
1336            }
1337        }
1338
1339        public boolean isEmpty() {
1340            return StringUtils.nullOnEmpty(toString()) == null && StringUtils.nullOnEmpty(toSims().getContent()) == null;
1341        }
1342
1343        public long getSize() {
1344            return size == null ? 0 : size;
1345        }
1346
1347        public String getName() {
1348            Element file = getFileElement();
1349            if (file == null) return null;
1350
1351            return file.findChildContent("name", file.getNamespace());
1352        }
1353
1354        public void setName(final String name) {
1355            if (sims == null) toSims();
1356            Element file = getFileElement();
1357
1358            for (Element child : file.getChildren()) {
1359                if (child.getName().equals("name") && child.getNamespace().equals(file.getNamespace())) {
1360                    file.removeChild(child);
1361                }
1362            }
1363
1364            if (name != null) {
1365                file.addChild("name", file.getNamespace()).setContent(name);
1366            }
1367        }
1368
1369        public String getMediaType() {
1370            Element file = getFileElement();
1371            if (file == null) return null;
1372
1373            return file.findChildContent("media-type", file.getNamespace());
1374        }
1375
1376        public void setMediaType(final String mime) {
1377            if (sims == null) toSims();
1378            Element file = getFileElement();
1379
1380            for (Element child : file.getChildren()) {
1381                if (child.getName().equals("media-type") && child.getNamespace().equals(file.getNamespace())) {
1382                    file.removeChild(child);
1383                }
1384            }
1385
1386            if (mime != null) {
1387                file.addChild("media-type", file.getNamespace()).setContent(mime);
1388            }
1389        }
1390
1391        public Element toSims() {
1392            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1393            sims.setAttribute("type", "data");
1394            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1395            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1396
1397            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1398            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1399            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1400            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1401
1402            file.removeChild(file.findChild("size", file.getNamespace()));
1403            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1404
1405            file.removeChild(file.findChild("width", "https://schema.org/"));
1406            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1407
1408            file.removeChild(file.findChild("height", "https://schema.org/"));
1409            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1410
1411            file.removeChild(file.findChild("duration", "https://schema.org/"));
1412            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1413
1414            if (url != null) {
1415                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1416                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1417
1418                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1419                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1420                source.setAttribute("type", "data");
1421                source.setAttribute("uri", url);
1422            }
1423
1424            return sims;
1425        }
1426
1427        protected Element getFileElement() {
1428            Element file = null;
1429            if (sims == null) return file;
1430
1431            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1432            if (mediaSharing == null) return file;
1433            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1434            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1435            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1436            return file;
1437        }
1438
1439        public void setCids(Iterable<Cid> cids) throws NoSuchAlgorithmException {
1440            if (sims == null) toSims();
1441            Element file = getFileElement();
1442
1443            for (Element child : file.getChildren()) {
1444                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1445                    file.removeChild(child);
1446                }
1447            }
1448
1449            for (Cid cid : cids) {
1450                file.addChild("hash", "urn:xmpp:hashes:2")
1451                    .setAttribute("algo", CryptoHelper.multihashAlgo(cid.getType()))
1452                    .setContent(Base64.encodeToString(cid.getHash(), Base64.NO_WRAP));
1453            }
1454        }
1455
1456        public List<Cid> getCids() {
1457            List<Cid> cids = new ArrayList<>();
1458            Element file = getFileElement();
1459            if (file == null) return cids;
1460
1461            for (Element child : file.getChildren()) {
1462                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1463                    try {
1464                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1465                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1466                }
1467            }
1468
1469            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1470
1471            return cids;
1472        }
1473
1474        public void addThumbnail(int width, int height, String mimeType, String uri) {
1475            for (Element thumb : getThumbnails()) {
1476                if (uri.equals(thumb.getAttribute("uri"))) return;
1477            }
1478
1479            if (sims == null) toSims();
1480            Element file = getFileElement();
1481            file.addChild(
1482                new Element("thumbnail", "urn:xmpp:thumbs:1")
1483                    .setAttribute("width", Integer.toString(width))
1484                    .setAttribute("height", Integer.toString(height))
1485                    .setAttribute("type", mimeType)
1486                    .setAttribute("uri", uri)
1487            );
1488        }
1489
1490        public List<Element> getThumbnails() {
1491            List<Element> thumbs = new ArrayList<>();
1492            Element file = getFileElement();
1493            if (file == null) return thumbs;
1494
1495            for (Element child : file.getChildren()) {
1496                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1497                    thumbs.add(child);
1498                }
1499            }
1500
1501            return thumbs;
1502        }
1503
1504        public String toString() {
1505            final StringBuilder builder = new StringBuilder();
1506            if (url != null) builder.append(url);
1507            if (size != null) builder.append('|').append(size.toString());
1508            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1509            if (height > 0 || runtime > 0) builder.append('|').append(height);
1510            if (runtime > 0) builder.append('|').append(runtime);
1511            return builder.toString();
1512        }
1513
1514        public boolean equals(Object o) {
1515            if (!(o instanceof FileParams)) return false;
1516            if (url == null) return false;
1517
1518            return url.equals(((FileParams) o).url);
1519        }
1520
1521        public int hashCode() {
1522            return url == null ? super.hashCode() : url.hashCode();
1523        }
1524    }
1525
1526    public void setFingerprint(String fingerprint) {
1527        this.axolotlFingerprint = fingerprint;
1528    }
1529
1530    public String getFingerprint() {
1531        return axolotlFingerprint;
1532    }
1533
1534    public boolean isTrusted() {
1535        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1536        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1537        return s != null && s.isTrusted();
1538    }
1539
1540    private int getPreviousEncryption() {
1541        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1542            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1543                continue;
1544            }
1545            return iterator.getEncryption();
1546        }
1547        return ENCRYPTION_NONE;
1548    }
1549
1550    private int getNextEncryption() {
1551        if (this.conversation instanceof Conversation) {
1552            Conversation conversation = (Conversation) this.conversation;
1553            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1554                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1555                    continue;
1556                }
1557                return iterator.getEncryption();
1558            }
1559            return conversation.getNextEncryption();
1560        } else {
1561            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1562        }
1563    }
1564
1565    public boolean isValidInSession() {
1566        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1567        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1568
1569        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1570                || futureEncryption == ENCRYPTION_NONE
1571                || pastEncryption != futureEncryption;
1572
1573        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1574    }
1575
1576    private static int getCleanedEncryption(int encryption) {
1577        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1578            return ENCRYPTION_PGP;
1579        }
1580        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1581            return ENCRYPTION_AXOLOTL;
1582        }
1583        return encryption;
1584    }
1585
1586    public static boolean configurePrivateMessage(final Message message) {
1587        return configurePrivateMessage(message, false);
1588    }
1589
1590    public static boolean configurePrivateFileMessage(final Message message) {
1591        return configurePrivateMessage(message, true);
1592    }
1593
1594    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1595        final Conversation conversation;
1596        if (message.conversation instanceof Conversation) {
1597            conversation = (Conversation) message.conversation;
1598        } else {
1599            return false;
1600        }
1601        if (conversation.getMode() == Conversation.MODE_MULTI) {
1602            final Jid nextCounterpart = conversation.getNextCounterpart();
1603            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1604        }
1605        return false;
1606    }
1607
1608    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1609        final Conversation conversation;
1610        if (message.conversation instanceof Conversation) {
1611            conversation = (Conversation) message.conversation;
1612        } else {
1613            return false;
1614        }
1615        return configurePrivateMessage(conversation, message, counterpart, false);
1616    }
1617
1618    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1619        if (counterpart == null) {
1620            return false;
1621        }
1622        message.setCounterpart(counterpart);
1623        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1624        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1625        return true;
1626    }
1627}