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