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