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