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.util.Log;
  11
  12import com.cheogram.android.BobTransfer;
  13import com.cheogram.android.GetThumbnailForCid;
  14
  15import com.google.common.io.ByteSource;
  16import com.google.common.base.Strings;
  17import com.google.common.collect.ImmutableSet;
  18import com.google.common.primitives.Longs;
  19
  20import org.json.JSONException;
  21
  22import java.lang.ref.WeakReference;
  23import java.io.IOException;
  24import java.net.URI;
  25import java.net.URISyntaxException;
  26import java.util.ArrayList;
  27import java.util.Arrays;
  28import java.util.Iterator;
  29import java.util.List;
  30import java.util.Set;
  31import java.util.stream.Collectors;
  32import java.util.concurrent.CopyOnWriteArraySet;
  33
  34import io.ipfs.cid.Cid;
  35
  36import eu.siacs.conversations.Config;
  37import eu.siacs.conversations.crypto.axolotl.AxolotlService;
  38import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
  39import eu.siacs.conversations.http.URL;
  40import eu.siacs.conversations.services.AvatarService;
  41import eu.siacs.conversations.ui.util.PresenceSelector;
  42import eu.siacs.conversations.utils.CryptoHelper;
  43import eu.siacs.conversations.utils.Emoticons;
  44import eu.siacs.conversations.utils.GeoHelper;
  45import eu.siacs.conversations.utils.MessageUtils;
  46import eu.siacs.conversations.utils.MimeUtils;
  47import eu.siacs.conversations.utils.UIHelper;
  48import eu.siacs.conversations.xmpp.Jid;
  49import eu.siacs.conversations.xml.Element;
  50import eu.siacs.conversations.xml.Tag;
  51import eu.siacs.conversations.xml.XmlReader;
  52
  53public class Message extends AbstractEntity implements AvatarService.Avatarable {
  54
  55    public static final String TABLENAME = "messages";
  56
  57    public static final int STATUS_RECEIVED = 0;
  58    public static final int STATUS_UNSEND = 1;
  59    public static final int STATUS_SEND = 2;
  60    public static final int STATUS_SEND_FAILED = 3;
  61    public static final int STATUS_WAITING = 5;
  62    public static final int STATUS_OFFERED = 6;
  63    public static final int STATUS_SEND_RECEIVED = 7;
  64    public static final int STATUS_SEND_DISPLAYED = 8;
  65
  66    public static final int ENCRYPTION_NONE = 0;
  67    public static final int ENCRYPTION_PGP = 1;
  68    public static final int ENCRYPTION_OTR = 2;
  69    public static final int ENCRYPTION_DECRYPTED = 3;
  70    public static final int ENCRYPTION_DECRYPTION_FAILED = 4;
  71    public static final int ENCRYPTION_AXOLOTL = 5;
  72    public static final int ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE = 6;
  73    public static final int ENCRYPTION_AXOLOTL_FAILED = 7;
  74
  75    public static final int TYPE_TEXT = 0;
  76    public static final int TYPE_IMAGE = 1;
  77    public static final int TYPE_FILE = 2;
  78    public static final int TYPE_STATUS = 3;
  79    public static final int TYPE_PRIVATE = 4;
  80    public static final int TYPE_PRIVATE_FILE = 5;
  81    public static final int TYPE_RTP_SESSION = 6;
  82
  83    public static final String CONVERSATION = "conversationUuid";
  84    public static final String COUNTERPART = "counterpart";
  85    public static final String TRUE_COUNTERPART = "trueCounterpart";
  86    public static final String BODY = "body";
  87    public static final String BODY_LANGUAGE = "bodyLanguage";
  88    public static final String TIME_SENT = "timeSent";
  89    public static final String ENCRYPTION = "encryption";
  90    public static final String STATUS = "status";
  91    public static final String TYPE = "type";
  92    public static final String CARBON = "carbon";
  93    public static final String OOB = "oob";
  94    public static final String EDITED = "edited";
  95    public static final String REMOTE_MSG_ID = "remoteMsgId";
  96    public static final String SERVER_MSG_ID = "serverMsgId";
  97    public static final String RELATIVE_FILE_PATH = "relativeFilePath";
  98    public static final String FINGERPRINT = "axolotl_fingerprint";
  99    public static final String READ = "read";
 100    public static final String ERROR_MESSAGE = "errorMsg";
 101    public static final String READ_BY_MARKERS = "readByMarkers";
 102    public static final String MARKABLE = "markable";
 103    public static final String DELETED = "deleted";
 104    public static final String ME_COMMAND = "/me ";
 105
 106    public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
 107
 108
 109    public boolean markable = false;
 110    protected String conversationUuid;
 111    protected Jid counterpart;
 112    protected Jid trueCounterpart;
 113    protected String body;
 114    protected String subject;
 115    protected String encryptedBody;
 116    protected long timeSent;
 117    protected int encryption;
 118    protected int status;
 119    protected int type;
 120    protected boolean deleted = false;
 121    protected boolean carbon = false;
 122    private boolean oob = false;
 123    protected URI oobUri = null;
 124    protected List<Element> payloads = new ArrayList<>();
 125    protected List<Edit> edits = new ArrayList<>();
 126    protected String relativeFilePath;
 127    protected boolean read = true;
 128    protected String remoteMsgId = null;
 129    private String bodyLanguage = null;
 130    protected String serverMsgId = null;
 131    private final Conversational conversation;
 132    protected Transferable transferable = null;
 133    private Message mNextMessage = null;
 134    private Message mPreviousMessage = null;
 135    private String axolotlFingerprint = null;
 136    private String errorMessage = null;
 137    private Set<ReadByMarker> readByMarkers = new CopyOnWriteArraySet<>();
 138
 139    private Boolean isGeoUri = null;
 140    private Boolean isEmojisOnly = null;
 141    private Boolean treatAsDownloadable = null;
 142    private FileParams fileParams = null;
 143    private List<MucOptions.User> counterparts;
 144    private WeakReference<MucOptions.User> user;
 145
 146    protected Message(Conversational conversation) {
 147        this.conversation = conversation;
 148    }
 149
 150    public Message(Conversational conversation, String body, int encryption) {
 151        this(conversation, body, encryption, STATUS_UNSEND);
 152    }
 153
 154    public Message(Conversational conversation, String body, int encryption, int status) {
 155        this(conversation, java.util.UUID.randomUUID().toString(),
 156                conversation.getUuid(),
 157                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 158                null,
 159                body,
 160                System.currentTimeMillis(),
 161                encryption,
 162                status,
 163                TYPE_TEXT,
 164                false,
 165                null,
 166                null,
 167                null,
 168                null,
 169                true,
 170                null,
 171                false,
 172                null,
 173                null,
 174                false,
 175                false,
 176                null,
 177                null,
 178                null,
 179                null,
 180                null);
 181    }
 182
 183    public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
 184        this(conversation, java.util.UUID.randomUUID().toString(),
 185                conversation.getUuid(),
 186                conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
 187                null,
 188                null,
 189                System.currentTimeMillis(),
 190                Message.ENCRYPTION_NONE,
 191                status,
 192                type,
 193                false,
 194                remoteMsgId,
 195                null,
 196                null,
 197                null,
 198                true,
 199                null,
 200                false,
 201                null,
 202                null,
 203                false,
 204                false,
 205                null,
 206                null,
 207                null,
 208                null,
 209                null);
 210    }
 211
 212    protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
 213                      final Jid trueCounterpart, final String body, final long timeSent,
 214                      final int encryption, final int status, final int type, final boolean carbon,
 215                      final String remoteMsgId, final String relativeFilePath,
 216                      final String serverMsgId, final String fingerprint, final boolean read,
 217                      final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
 218                      final boolean markable, final boolean deleted, final String bodyLanguage, final String subject, final String oobUri, final String fileParams, final List<Element> payloads) {
 219        this.conversation = conversation;
 220        this.uuid = uuid;
 221        this.conversationUuid = conversationUUid;
 222        this.counterpart = counterpart;
 223        this.trueCounterpart = trueCounterpart;
 224        this.body = body == null ? "" : body;
 225        this.timeSent = timeSent;
 226        this.encryption = encryption;
 227        this.status = status;
 228        this.type = type;
 229        this.carbon = carbon;
 230        this.remoteMsgId = remoteMsgId;
 231        this.relativeFilePath = relativeFilePath;
 232        this.serverMsgId = serverMsgId;
 233        this.axolotlFingerprint = fingerprint;
 234        this.read = read;
 235        this.edits = Edit.fromJson(edited);
 236        setOob(oobUri);
 237        this.oob = oob;
 238        this.errorMessage = errorMessage;
 239        this.readByMarkers = readByMarkers == null ? new CopyOnWriteArraySet<>() : readByMarkers;
 240        this.markable = markable;
 241        this.deleted = deleted;
 242        this.bodyLanguage = bodyLanguage;
 243        this.subject = subject;
 244        if (fileParams != null) this.fileParams = new FileParams(fileParams);
 245        if (payloads != null) this.payloads = payloads;
 246    }
 247
 248    public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
 249        String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
 250        List<Element> payloads = new ArrayList<>();
 251        if (payloadsStr != null) {
 252            final XmlReader xmlReader = new XmlReader();
 253            xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
 254            Tag tag;
 255            while ((tag = xmlReader.readTag()) != null) {
 256                payloads.add(xmlReader.readElement(tag));
 257            }
 258        }
 259
 260        return new Message(conversation,
 261                cursor.getString(cursor.getColumnIndex(UUID)),
 262                cursor.getString(cursor.getColumnIndex(CONVERSATION)),
 263                fromString(cursor.getString(cursor.getColumnIndex(COUNTERPART))),
 264                fromString(cursor.getString(cursor.getColumnIndex(TRUE_COUNTERPART))),
 265                cursor.getString(cursor.getColumnIndex(BODY)),
 266                cursor.getLong(cursor.getColumnIndex(TIME_SENT)),
 267                cursor.getInt(cursor.getColumnIndex(ENCRYPTION)),
 268                cursor.getInt(cursor.getColumnIndex(STATUS)),
 269                cursor.getInt(cursor.getColumnIndex(TYPE)),
 270                cursor.getInt(cursor.getColumnIndex(CARBON)) > 0,
 271                cursor.getString(cursor.getColumnIndex(REMOTE_MSG_ID)),
 272                cursor.getString(cursor.getColumnIndex(RELATIVE_FILE_PATH)),
 273                cursor.getString(cursor.getColumnIndex(SERVER_MSG_ID)),
 274                cursor.getString(cursor.getColumnIndex(FINGERPRINT)),
 275                cursor.getInt(cursor.getColumnIndex(READ)) > 0,
 276                cursor.getString(cursor.getColumnIndex(EDITED)),
 277                cursor.getInt(cursor.getColumnIndex(OOB)) > 0,
 278                cursor.getString(cursor.getColumnIndex(ERROR_MESSAGE)),
 279                ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))),
 280                cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0,
 281                cursor.getInt(cursor.getColumnIndex(DELETED)) > 0,
 282                cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
 283                cursor.getString(cursor.getColumnIndex("subject")),
 284                cursor.getString(cursor.getColumnIndex("oobUri")),
 285                cursor.getString(cursor.getColumnIndex("fileParams")),
 286                payloads
 287        );
 288    }
 289
 290    private static Jid fromString(String value) {
 291        try {
 292            if (value != null) {
 293                return Jid.of(value);
 294            }
 295        } catch (IllegalArgumentException e) {
 296            return null;
 297        }
 298        return null;
 299    }
 300
 301    public static Message createStatusMessage(Conversation conversation, String body) {
 302        final Message message = new Message(conversation);
 303        message.setType(Message.TYPE_STATUS);
 304        message.setStatus(Message.STATUS_RECEIVED);
 305        message.body = body;
 306        return message;
 307    }
 308
 309    public static Message createLoadMoreMessage(Conversation conversation) {
 310        final Message message = new Message(conversation);
 311        message.setType(Message.TYPE_STATUS);
 312        message.body = "LOAD_MORE";
 313        return message;
 314    }
 315
 316    public ContentValues getCheogramContentValues() {
 317        ContentValues values = new ContentValues();
 318        values.put(UUID, uuid);
 319        values.put("subject", subject);
 320        values.put("oobUri", oobUri == null ? null : oobUri.toString());
 321        values.put("fileParams", fileParams == null ? null : fileParams.toString());
 322        values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
 323        return values;
 324    }
 325
 326    @Override
 327    public ContentValues getContentValues() {
 328        ContentValues values = new ContentValues();
 329        values.put(UUID, uuid);
 330        values.put(CONVERSATION, conversationUuid);
 331        if (counterpart == null) {
 332            values.putNull(COUNTERPART);
 333        } else {
 334            values.put(COUNTERPART, counterpart.toString());
 335        }
 336        if (trueCounterpart == null) {
 337            values.putNull(TRUE_COUNTERPART);
 338        } else {
 339            values.put(TRUE_COUNTERPART, trueCounterpart.toString());
 340        }
 341        values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
 342        values.put(TIME_SENT, timeSent);
 343        values.put(ENCRYPTION, encryption);
 344        values.put(STATUS, status);
 345        values.put(TYPE, type);
 346        values.put(CARBON, carbon ? 1 : 0);
 347        values.put(REMOTE_MSG_ID, remoteMsgId);
 348        values.put(RELATIVE_FILE_PATH, relativeFilePath);
 349        values.put(SERVER_MSG_ID, serverMsgId);
 350        values.put(FINGERPRINT, axolotlFingerprint);
 351        values.put(READ, read ? 1 : 0);
 352        try {
 353            values.put(EDITED, Edit.toJson(edits));
 354        } catch (JSONException e) {
 355            Log.e(Config.LOGTAG, "error persisting json for edits", e);
 356        }
 357        values.put(OOB, oob ? 1 : 0);
 358        values.put(ERROR_MESSAGE, errorMessage);
 359        values.put(READ_BY_MARKERS, ReadByMarker.toJson(readByMarkers).toString());
 360        values.put(MARKABLE, markable ? 1 : 0);
 361        values.put(DELETED, deleted ? 1 : 0);
 362        values.put(BODY_LANGUAGE, bodyLanguage);
 363        return values;
 364    }
 365
 366    public String getConversationUuid() {
 367        return conversationUuid;
 368    }
 369
 370    public Conversational getConversation() {
 371        return this.conversation;
 372    }
 373
 374    public Jid getCounterpart() {
 375        return counterpart;
 376    }
 377
 378    public void setCounterpart(final Jid counterpart) {
 379        this.counterpart = counterpart;
 380    }
 381
 382    public Contact getContact() {
 383        if (this.conversation.getMode() == Conversation.MODE_SINGLE) {
 384            return this.conversation.getContact();
 385        } else {
 386            if (this.trueCounterpart == null) {
 387                return null;
 388            } else {
 389                return this.conversation.getAccount().getRoster()
 390                        .getContactFromContactList(this.trueCounterpart);
 391            }
 392        }
 393    }
 394
 395    public String getBody() {
 396        if (oobUri != null) {
 397            return body.replace(oobUri.toString(), "");
 398        } else {
 399            return body;
 400        }
 401    }
 402
 403    public synchronized void setBody(String body) {
 404        if (body == null) {
 405            throw new Error("You should not set the message body to null");
 406        }
 407        this.body = body;
 408        this.isGeoUri = null;
 409        this.isEmojisOnly = null;
 410        this.treatAsDownloadable = null;
 411    }
 412
 413    public String getSubject() {
 414        return subject;
 415    }
 416
 417    public synchronized void setSubject(String subject) {
 418        this.subject = subject;
 419    }
 420
 421    public void setMucUser(MucOptions.User user) {
 422        this.user = new WeakReference<>(user);
 423    }
 424
 425    public boolean sameMucUser(Message otherMessage) {
 426        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 427        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 428        return thisUser != null && thisUser == otherUser;
 429    }
 430
 431    public String getErrorMessage() {
 432        return errorMessage;
 433    }
 434
 435    public boolean setErrorMessage(String message) {
 436        boolean changed = (message != null && !message.equals(errorMessage))
 437                || (message == null && errorMessage != null);
 438        this.errorMessage = message;
 439        return changed;
 440    }
 441
 442    public long getTimeSent() {
 443        return timeSent;
 444    }
 445
 446    public int getEncryption() {
 447        return encryption;
 448    }
 449
 450    public void setEncryption(int encryption) {
 451        this.encryption = encryption;
 452    }
 453
 454    public int getStatus() {
 455        return status;
 456    }
 457
 458    public void setStatus(int status) {
 459        this.status = status;
 460    }
 461
 462    public String getRelativeFilePath() {
 463        return this.relativeFilePath;
 464    }
 465
 466    public void setRelativeFilePath(String path) {
 467        this.relativeFilePath = path;
 468    }
 469
 470    public String getRemoteMsgId() {
 471        return this.remoteMsgId;
 472    }
 473
 474    public void setRemoteMsgId(String id) {
 475        this.remoteMsgId = id;
 476    }
 477
 478    public String getServerMsgId() {
 479        return this.serverMsgId;
 480    }
 481
 482    public void setServerMsgId(String id) {
 483        this.serverMsgId = id;
 484    }
 485
 486    public boolean isRead() {
 487        return this.read;
 488    }
 489
 490    public boolean isDeleted() {
 491        return this.deleted;
 492    }
 493
 494    public void setDeleted(boolean deleted) {
 495        this.deleted = deleted;
 496    }
 497
 498    public void markRead() {
 499        this.read = true;
 500    }
 501
 502    public void markUnread() {
 503        this.read = false;
 504    }
 505
 506    public void setTime(long time) {
 507        this.timeSent = time;
 508    }
 509
 510    public String getEncryptedBody() {
 511        return this.encryptedBody;
 512    }
 513
 514    public void setEncryptedBody(String body) {
 515        this.encryptedBody = body;
 516    }
 517
 518    public int getType() {
 519        return this.type;
 520    }
 521
 522    public void setType(int type) {
 523        this.type = type;
 524    }
 525
 526    public boolean isCarbon() {
 527        return carbon;
 528    }
 529
 530    public void setCarbon(boolean carbon) {
 531        this.carbon = carbon;
 532    }
 533
 534    public void putEdited(String edited, String serverMsgId) {
 535        final Edit edit = new Edit(edited, serverMsgId);
 536        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 537            this.edits.add(edit);
 538        }
 539    }
 540
 541    boolean remoteMsgIdMatchInEdit(String id) {
 542        for (Edit edit : this.edits) {
 543            if (id.equals(edit.getEditedId())) {
 544                return true;
 545            }
 546        }
 547        return false;
 548    }
 549
 550    public String getBodyLanguage() {
 551        return this.bodyLanguage;
 552    }
 553
 554    public void setBodyLanguage(String language) {
 555        this.bodyLanguage = language;
 556    }
 557
 558    public boolean edited() {
 559        return this.edits.size() > 0;
 560    }
 561
 562    public void setTrueCounterpart(Jid trueCounterpart) {
 563        this.trueCounterpart = trueCounterpart;
 564    }
 565
 566    public Jid getTrueCounterpart() {
 567        return this.trueCounterpart;
 568    }
 569
 570    public Transferable getTransferable() {
 571        return this.transferable;
 572    }
 573
 574    public synchronized void setTransferable(Transferable transferable) {
 575        this.transferable = transferable;
 576    }
 577
 578    public boolean addReadByMarker(ReadByMarker readByMarker) {
 579        if (readByMarker.getRealJid() != null) {
 580            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 581                return false;
 582            }
 583        } else if (readByMarker.getFullJid() != null) {
 584            if (readByMarker.getFullJid().equals(counterpart)) {
 585                return false;
 586            }
 587        }
 588        if (this.readByMarkers.add(readByMarker)) {
 589            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 590                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 591                while (iterator.hasNext()) {
 592                    ReadByMarker marker = iterator.next();
 593                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 594                        iterator.remove();
 595                    }
 596                }
 597            }
 598            return true;
 599        } else {
 600            return false;
 601        }
 602    }
 603
 604    public Set<ReadByMarker> getReadByMarkers() {
 605        return ImmutableSet.copyOf(this.readByMarkers);
 606    }
 607
 608    boolean similar(Message message) {
 609        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 610            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 611        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 612            return true;
 613        } else if (this.body == null || this.counterpart == null) {
 614            return false;
 615        } else {
 616            String body, otherBody;
 617            if (this.hasFileOnRemoteHost()) {
 618                body = getFileParams().url;
 619                otherBody = message.body == null ? null : message.body.trim();
 620            } else {
 621                body = this.body;
 622                otherBody = message.body;
 623            }
 624            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 625            if (message.getRemoteMsgId() != null) {
 626                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 627                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 628                    return true;
 629                }
 630                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 631                        && matchingCounterpart
 632                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 633            } else {
 634                return this.remoteMsgId == null
 635                        && matchingCounterpart
 636                        && body.equals(otherBody)
 637                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 638            }
 639        }
 640    }
 641
 642    public Message next() {
 643        if (this.conversation instanceof Conversation) {
 644            final Conversation conversation = (Conversation) this.conversation;
 645            synchronized (conversation.messages) {
 646                if (this.mNextMessage == null) {
 647                    int index = conversation.messages.indexOf(this);
 648                    if (index < 0 || index >= conversation.messages.size() - 1) {
 649                        this.mNextMessage = null;
 650                    } else {
 651                        this.mNextMessage = conversation.messages.get(index + 1);
 652                    }
 653                }
 654                return this.mNextMessage;
 655            }
 656        } else {
 657            throw new AssertionError("Calling next should be disabled for stubs");
 658        }
 659    }
 660
 661    public Message prev() {
 662        if (this.conversation instanceof Conversation) {
 663            final Conversation conversation = (Conversation) this.conversation;
 664            synchronized (conversation.messages) {
 665                if (this.mPreviousMessage == null) {
 666                    int index = conversation.messages.indexOf(this);
 667                    if (index <= 0 || index > conversation.messages.size()) {
 668                        this.mPreviousMessage = null;
 669                    } else {
 670                        this.mPreviousMessage = conversation.messages.get(index - 1);
 671                    }
 672                }
 673            }
 674            return this.mPreviousMessage;
 675        } else {
 676            throw new AssertionError("Calling prev should be disabled for stubs");
 677        }
 678    }
 679
 680    public boolean isLastCorrectableMessage() {
 681        Message next = next();
 682        while (next != null) {
 683            if (next.isEditable()) {
 684                return false;
 685            }
 686            next = next.next();
 687        }
 688        return isEditable();
 689    }
 690
 691    public boolean isEditable() {
 692        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 693    }
 694
 695    public boolean mergeable(final Message message) {
 696        return message != null &&
 697                (message.getType() == Message.TYPE_TEXT &&
 698                        this.getTransferable() == null &&
 699                        message.getTransferable() == null &&
 700                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 701                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 702                        this.getType() == message.getType() &&
 703                        this.getSubject() != null &&
 704                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 705                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 706                        this.getCounterpart() != null &&
 707                        this.getCounterpart().equals(message.getCounterpart()) &&
 708                        this.edited() == message.edited() &&
 709                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 710                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 711                        !message.isGeoUri() &&
 712                        !this.isGeoUri() &&
 713                        !message.isOOb() &&
 714                        !this.isOOb() &&
 715                        !message.treatAsDownloadable() &&
 716                        !this.treatAsDownloadable() &&
 717                        !message.hasMeCommand() &&
 718                        !this.hasMeCommand() &&
 719                        !this.bodyIsOnlyEmojis() &&
 720                        !message.bodyIsOnlyEmojis() &&
 721                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 722                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 723                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 724                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 725                );
 726    }
 727
 728    private static boolean isStatusMergeable(int a, int b) {
 729        return a == b || (
 730                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 731                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 732                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 733                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 734                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 735        );
 736    }
 737
 738    private static boolean isEncryptionMergeable(final int a, final int b) {
 739        return a == b
 740                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 741                        .contains(a);
 742    }
 743
 744    public void setCounterparts(List<MucOptions.User> counterparts) {
 745        this.counterparts = counterparts;
 746    }
 747
 748    public List<MucOptions.User> getCounterparts() {
 749        return this.counterparts;
 750    }
 751
 752    @Override
 753    public int getAvatarBackgroundColor() {
 754        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 755            return Color.TRANSPARENT;
 756        } else {
 757            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 758        }
 759    }
 760
 761    @Override
 762    public String getAvatarName() {
 763        return UIHelper.getMessageDisplayName(this);
 764    }
 765
 766    public boolean isOOb() {
 767        return oob || oobUri != null;
 768    }
 769
 770    public static class MergeSeparator {
 771    }
 772
 773    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 774        final Element html = getHtml();
 775        if (html == null || Build.VERSION.SDK_INT < 24) {
 776            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
 777        } else {
 778            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
 779                MessageUtils.filterLtrRtl(html.toString()).trim(),
 780                Html.FROM_HTML_MODE_COMPACT,
 781                (source) -> {
 782                   try {
 783                       if (thumbnailer == null) return fallbackImg;
 784                       Cid cid = BobTransfer.cid(new URI(source));
 785                       if (cid == null) return fallbackImg;
 786                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
 787                       if (thumbnail == null) return fallbackImg;
 788                       return thumbnail;
 789                   } catch (final URISyntaxException e) {
 790                       return fallbackImg;
 791                   }
 792                },
 793                (opening, tag, output, xmlReader) -> {}
 794            ));
 795
 796            // https://stackoverflow.com/a/10187511/8611
 797            int i = spannable.length();
 798            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
 799            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
 800        }
 801    }
 802
 803    public SpannableStringBuilder getMergedBody() {
 804        return getMergedBody(null, null);
 805    }
 806
 807    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 808        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
 809        Message current = this;
 810        while (current.mergeable(current.next())) {
 811            current = current.next();
 812            if (current == null) {
 813                break;
 814            }
 815            body.append("\n\n");
 816            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 817                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 818            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
 819        }
 820        return body;
 821    }
 822
 823    public boolean hasMeCommand() {
 824        return this.body.trim().startsWith(ME_COMMAND);
 825    }
 826
 827    public int getMergedStatus() {
 828        int status = this.status;
 829        Message current = this;
 830        while (current.mergeable(current.next())) {
 831            current = current.next();
 832            if (current == null) {
 833                break;
 834            }
 835            status = current.status;
 836        }
 837        return status;
 838    }
 839
 840    public long getMergedTimeSent() {
 841        long time = this.timeSent;
 842        Message current = this;
 843        while (current.mergeable(current.next())) {
 844            current = current.next();
 845            if (current == null) {
 846                break;
 847            }
 848            time = current.timeSent;
 849        }
 850        return time;
 851    }
 852
 853    public boolean wasMergedIntoPrevious() {
 854        Message prev = this.prev();
 855        return prev != null && prev.mergeable(this);
 856    }
 857
 858    public boolean trusted() {
 859        Contact contact = this.getContact();
 860        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
 861    }
 862
 863    public boolean fixCounterpart() {
 864        final Presences presences = conversation.getContact().getPresences();
 865        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
 866            return true;
 867        } else if (presences.size() >= 1) {
 868            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
 869            return true;
 870        } else {
 871            counterpart = null;
 872            return false;
 873        }
 874    }
 875
 876    public void setUuid(String uuid) {
 877        this.uuid = uuid;
 878    }
 879
 880    public String getEditedId() {
 881        if (edits.size() > 0) {
 882            return edits.get(edits.size() - 1).getEditedId();
 883        } else {
 884            throw new IllegalStateException("Attempting to store unedited message");
 885        }
 886    }
 887
 888    public String getEditedIdWireFormat() {
 889        if (edits.size() > 0) {
 890            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
 891        } else {
 892            throw new IllegalStateException("Attempting to store unedited message");
 893        }
 894    }
 895
 896    public URI getOob() {
 897        return oobUri;
 898    }
 899
 900    public void setOob(String oobUri) {
 901        try {
 902            this.oobUri = oobUri == null ? null : new URI(oobUri);
 903        } catch (final URISyntaxException e) {
 904            this.oobUri = null;
 905        }
 906        this.oob = this.oobUri != null;
 907    }
 908
 909    public void addPayload(Element el) {
 910        this.payloads.add(el);
 911    }
 912
 913    public Element getHtml() {
 914        if (this.payloads == null) return null;
 915
 916        for (Element el : this.payloads) {
 917            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
 918                return el.getChildren().get(0);
 919            }
 920        }
 921
 922        return null;
 923    }
 924
 925    public List<Element> getCommands() {
 926        if (this.payloads == null) return null;
 927
 928        for (Element el : this.payloads) {
 929            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
 930                return el.getChildren();
 931            }
 932        }
 933
 934        return null;
 935    }
 936
 937    public String getMimeType() {
 938        String extension;
 939        if (relativeFilePath != null) {
 940            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
 941        } else {
 942            final String url = URL.tryParse(oobUri == null ? body.split("\n")[0] : oobUri.toString());
 943            if (url == null) {
 944                return null;
 945            }
 946            extension = MimeUtils.extractRelevantExtension(url);
 947        }
 948        return MimeUtils.guessMimeTypeFromExtension(extension);
 949    }
 950
 951    public synchronized boolean treatAsDownloadable() {
 952        if (treatAsDownloadable == null) {
 953            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
 954        }
 955        return treatAsDownloadable;
 956    }
 957
 958    public synchronized boolean bodyIsOnlyEmojis() {
 959        if (isEmojisOnly == null) {
 960            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
 961        }
 962        return isEmojisOnly;
 963    }
 964
 965    public synchronized boolean isGeoUri() {
 966        if (isGeoUri == null) {
 967            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
 968        }
 969        return isGeoUri;
 970    }
 971
 972    public synchronized void resetFileParams() {
 973        this.fileParams = null;
 974    }
 975
 976    public synchronized void setFileParams(FileParams fileParams) {
 977        this.fileParams = fileParams;
 978    }
 979
 980    public synchronized FileParams getFileParams() {
 981        if (fileParams == null) {
 982            fileParams = new FileParams(this.body);
 983            if (this.transferable != null) {
 984                fileParams.size = this.transferable.getFileSize();
 985            }
 986
 987            if (oobUri != null && ("http".equalsIgnoreCase(oobUri.getScheme()) || "https".equalsIgnoreCase(oobUri.getScheme()) || "cid".equalsIgnoreCase(oobUri.getScheme()))) {
 988                fileParams.url = oobUri.toString();
 989            }
 990        }
 991        return fileParams;
 992    }
 993
 994    private static int parseInt(String value) {
 995        try {
 996            return Integer.parseInt(value);
 997        } catch (NumberFormatException e) {
 998            return 0;
 999        }
1000    }
1001
1002    public void untie() {
1003        this.mNextMessage = null;
1004        this.mPreviousMessage = null;
1005    }
1006
1007    public boolean isPrivateMessage() {
1008        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1009    }
1010
1011    public boolean isFileOrImage() {
1012        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1013    }
1014
1015
1016    public boolean isTypeText() {
1017        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1018    }
1019
1020    public boolean hasFileOnRemoteHost() {
1021        return isFileOrImage() && getFileParams().url != null;
1022    }
1023
1024    public boolean needsUploading() {
1025        return isFileOrImage() && getFileParams().url == null;
1026    }
1027
1028    public static class FileParams {
1029        public String url;
1030        public Long size = null;
1031        public int width = 0;
1032        public int height = 0;
1033        public int runtime = 0;
1034
1035        public FileParams() { }
1036
1037        public FileParams(String ser) {
1038            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1039            switch (parts.length) {
1040                case 1:
1041                    try {
1042                        this.size = Long.parseLong(parts[0]);
1043                    } catch (final NumberFormatException e) {
1044                        this.url = URL.tryParse(parts[0]);
1045                    }
1046                    break;
1047                case 5:
1048                    this.runtime = parseInt(parts[4]);
1049                case 4:
1050                    this.width = parseInt(parts[2]);
1051                    this.height = parseInt(parts[3]);
1052                case 2:
1053                    this.url = URL.tryParse(parts[0]);
1054                    this.size = Longs.tryParse(parts[1]);
1055                    break;
1056                case 3:
1057                    this.size = Longs.tryParse(parts[0]);
1058                    this.width = parseInt(parts[1]);
1059                    this.height = parseInt(parts[2]);
1060                    break;
1061            }
1062        }
1063
1064        public long getSize() {
1065            return size == null ? 0 : size;
1066        }
1067
1068        public String toString() {
1069            final StringBuilder builder = new StringBuilder();
1070            if (url != null) builder.append(url);
1071            if (size != null) builder.append('|').append(size.toString());
1072            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1073            if (height > 0 || runtime > 0) builder.append('|').append(height);
1074            if (runtime > 0) builder.append('|').append(runtime);
1075            return builder.toString();
1076        }
1077    }
1078
1079    public void setFingerprint(String fingerprint) {
1080        this.axolotlFingerprint = fingerprint;
1081    }
1082
1083    public String getFingerprint() {
1084        return axolotlFingerprint;
1085    }
1086
1087    public boolean isTrusted() {
1088        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1089        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1090        return s != null && s.isTrusted();
1091    }
1092
1093    private int getPreviousEncryption() {
1094        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1095            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1096                continue;
1097            }
1098            return iterator.getEncryption();
1099        }
1100        return ENCRYPTION_NONE;
1101    }
1102
1103    private int getNextEncryption() {
1104        if (this.conversation instanceof Conversation) {
1105            Conversation conversation = (Conversation) this.conversation;
1106            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1107                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1108                    continue;
1109                }
1110                return iterator.getEncryption();
1111            }
1112            return conversation.getNextEncryption();
1113        } else {
1114            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1115        }
1116    }
1117
1118    public boolean isValidInSession() {
1119        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1120        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1121
1122        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1123                || futureEncryption == ENCRYPTION_NONE
1124                || pastEncryption != futureEncryption;
1125
1126        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1127    }
1128
1129    private static int getCleanedEncryption(int encryption) {
1130        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1131            return ENCRYPTION_PGP;
1132        }
1133        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1134            return ENCRYPTION_AXOLOTL;
1135        }
1136        return encryption;
1137    }
1138
1139    public static boolean configurePrivateMessage(final Message message) {
1140        return configurePrivateMessage(message, false);
1141    }
1142
1143    public static boolean configurePrivateFileMessage(final Message message) {
1144        return configurePrivateMessage(message, true);
1145    }
1146
1147    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1148        final Conversation conversation;
1149        if (message.conversation instanceof Conversation) {
1150            conversation = (Conversation) message.conversation;
1151        } else {
1152            return false;
1153        }
1154        if (conversation.getMode() == Conversation.MODE_MULTI) {
1155            final Jid nextCounterpart = conversation.getNextCounterpart();
1156            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1157        }
1158        return false;
1159    }
1160
1161    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1162        final Conversation conversation;
1163        if (message.conversation instanceof Conversation) {
1164            conversation = (Conversation) message.conversation;
1165        } else {
1166            return false;
1167        }
1168        return configurePrivateMessage(conversation, message, counterpart, false);
1169    }
1170
1171    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1172        if (counterpart == null) {
1173            return false;
1174        }
1175        message.setCounterpart(counterpart);
1176        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1177        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1178        return true;
1179    }
1180}