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