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 setMucUser(MucOptions.User user) {
 434        this.user = new WeakReference<>(user);
 435    }
 436
 437    public boolean sameMucUser(Message otherMessage) {
 438        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 439        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 440        return thisUser != null && thisUser == otherUser;
 441    }
 442
 443    public String getErrorMessage() {
 444        return errorMessage;
 445    }
 446
 447    public boolean setErrorMessage(String message) {
 448        boolean changed = (message != null && !message.equals(errorMessage))
 449                || (message == null && errorMessage != null);
 450        this.errorMessage = message;
 451        return changed;
 452    }
 453
 454    public long getTimeReceived() {
 455        return timeReceived;
 456    }
 457
 458    public long getTimeSent() {
 459        return timeSent;
 460    }
 461
 462    public int getEncryption() {
 463        return encryption;
 464    }
 465
 466    public void setEncryption(int encryption) {
 467        this.encryption = encryption;
 468    }
 469
 470    public int getStatus() {
 471        return status;
 472    }
 473
 474    public void setStatus(int status) {
 475        this.status = status;
 476    }
 477
 478    public String getRelativeFilePath() {
 479        return this.relativeFilePath;
 480    }
 481
 482    public void setRelativeFilePath(String path) {
 483        this.relativeFilePath = path;
 484    }
 485
 486    public String getRemoteMsgId() {
 487        return this.remoteMsgId;
 488    }
 489
 490    public void setRemoteMsgId(String id) {
 491        this.remoteMsgId = id;
 492    }
 493
 494    public String getServerMsgId() {
 495        return this.serverMsgId;
 496    }
 497
 498    public void setServerMsgId(String id) {
 499        this.serverMsgId = id;
 500    }
 501
 502    public boolean isRead() {
 503        return this.read;
 504    }
 505
 506    public boolean isDeleted() {
 507        return this.deleted;
 508    }
 509
 510    public void setDeleted(boolean deleted) {
 511        this.deleted = deleted;
 512    }
 513
 514    public void markRead() {
 515        this.read = true;
 516    }
 517
 518    public void markUnread() {
 519        this.read = false;
 520    }
 521
 522    public void setTime(long time) {
 523        this.timeSent = time;
 524    }
 525
 526    public String getEncryptedBody() {
 527        return this.encryptedBody;
 528    }
 529
 530    public void setEncryptedBody(String body) {
 531        this.encryptedBody = body;
 532    }
 533
 534    public int getType() {
 535        return this.type;
 536    }
 537
 538    public void setType(int type) {
 539        this.type = type;
 540    }
 541
 542    public boolean isCarbon() {
 543        return carbon;
 544    }
 545
 546    public void setCarbon(boolean carbon) {
 547        this.carbon = carbon;
 548    }
 549
 550    public void putEdited(String edited, String serverMsgId) {
 551        final Edit edit = new Edit(edited, serverMsgId);
 552        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 553            this.edits.add(edit);
 554        }
 555    }
 556
 557    boolean remoteMsgIdMatchInEdit(String id) {
 558        for (Edit edit : this.edits) {
 559            if (id.equals(edit.getEditedId())) {
 560                return true;
 561            }
 562        }
 563        return false;
 564    }
 565
 566    public String getBodyLanguage() {
 567        return this.bodyLanguage;
 568    }
 569
 570    public void setBodyLanguage(String language) {
 571        this.bodyLanguage = language;
 572    }
 573
 574    public boolean edited() {
 575        return this.edits.size() > 0;
 576    }
 577
 578    public void setTrueCounterpart(Jid trueCounterpart) {
 579        this.trueCounterpart = trueCounterpart;
 580    }
 581
 582    public Jid getTrueCounterpart() {
 583        return this.trueCounterpart;
 584    }
 585
 586    public Transferable getTransferable() {
 587        return this.transferable;
 588    }
 589
 590    public synchronized void setTransferable(Transferable transferable) {
 591        this.transferable = transferable;
 592    }
 593
 594    public boolean addReadByMarker(ReadByMarker readByMarker) {
 595        if (readByMarker.getRealJid() != null) {
 596            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 597                return false;
 598            }
 599        } else if (readByMarker.getFullJid() != null) {
 600            if (readByMarker.getFullJid().equals(counterpart)) {
 601                return false;
 602            }
 603        }
 604        if (this.readByMarkers.add(readByMarker)) {
 605            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 606                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 607                while (iterator.hasNext()) {
 608                    ReadByMarker marker = iterator.next();
 609                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 610                        iterator.remove();
 611                    }
 612                }
 613            }
 614            return true;
 615        } else {
 616            return false;
 617        }
 618    }
 619
 620    public Set<ReadByMarker> getReadByMarkers() {
 621        return ImmutableSet.copyOf(this.readByMarkers);
 622    }
 623
 624    boolean similar(Message message) {
 625        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 626            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 627        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 628            return true;
 629        } else if (this.body == null || this.counterpart == null) {
 630            return false;
 631        } else {
 632            String body, otherBody;
 633            if (this.hasFileOnRemoteHost()) {
 634                body = getFileParams().url;
 635                otherBody = message.body == null ? null : message.body.trim();
 636            } else {
 637                body = this.body;
 638                otherBody = message.body;
 639            }
 640            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 641            if (message.getRemoteMsgId() != null) {
 642                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 643                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 644                    return true;
 645                }
 646                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 647                        && matchingCounterpart
 648                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 649            } else {
 650                return this.remoteMsgId == null
 651                        && matchingCounterpart
 652                        && body.equals(otherBody)
 653                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 654            }
 655        }
 656    }
 657
 658    public Message next() {
 659        if (this.conversation instanceof Conversation) {
 660            final Conversation conversation = (Conversation) this.conversation;
 661            synchronized (conversation.messages) {
 662                if (this.mNextMessage == null) {
 663                    int index = conversation.messages.indexOf(this);
 664                    if (index < 0 || index >= conversation.messages.size() - 1) {
 665                        this.mNextMessage = null;
 666                    } else {
 667                        this.mNextMessage = conversation.messages.get(index + 1);
 668                    }
 669                }
 670                return this.mNextMessage;
 671            }
 672        } else {
 673            throw new AssertionError("Calling next should be disabled for stubs");
 674        }
 675    }
 676
 677    public Message prev() {
 678        if (this.conversation instanceof Conversation) {
 679            final Conversation conversation = (Conversation) this.conversation;
 680            synchronized (conversation.messages) {
 681                if (this.mPreviousMessage == null) {
 682                    int index = conversation.messages.indexOf(this);
 683                    if (index <= 0 || index > conversation.messages.size()) {
 684                        this.mPreviousMessage = null;
 685                    } else {
 686                        this.mPreviousMessage = conversation.messages.get(index - 1);
 687                    }
 688                }
 689            }
 690            return this.mPreviousMessage;
 691        } else {
 692            throw new AssertionError("Calling prev should be disabled for stubs");
 693        }
 694    }
 695
 696    public boolean isLastCorrectableMessage() {
 697        Message next = next();
 698        while (next != null) {
 699            if (next.isEditable()) {
 700                return false;
 701            }
 702            next = next.next();
 703        }
 704        return isEditable();
 705    }
 706
 707    public boolean isEditable() {
 708        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 709    }
 710
 711    public boolean mergeable(final Message message) {
 712        return message != null &&
 713                (message.getType() == Message.TYPE_TEXT &&
 714                        this.getTransferable() == null &&
 715                        message.getTransferable() == null &&
 716                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 717                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 718                        this.getType() == message.getType() &&
 719                        this.getSubject() != null &&
 720                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 721                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 722                        this.getCounterpart() != null &&
 723                        this.getCounterpart().equals(message.getCounterpart()) &&
 724                        this.edited() == message.edited() &&
 725                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 726                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 727                        !message.isGeoUri() &&
 728                        !this.isGeoUri() &&
 729                        !message.isOOb() &&
 730                        !this.isOOb() &&
 731                        !message.treatAsDownloadable() &&
 732                        !this.treatAsDownloadable() &&
 733                        !message.hasMeCommand() &&
 734                        !this.hasMeCommand() &&
 735                        !this.bodyIsOnlyEmojis() &&
 736                        !message.bodyIsOnlyEmojis() &&
 737                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 738                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 739                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 740                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 741                );
 742    }
 743
 744    private static boolean isStatusMergeable(int a, int b) {
 745        return a == b || (
 746                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 747                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 748                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 749                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 750                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 751        );
 752    }
 753
 754    private static boolean isEncryptionMergeable(final int a, final int b) {
 755        return a == b
 756                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 757                        .contains(a);
 758    }
 759
 760    public void setCounterparts(List<MucOptions.User> counterparts) {
 761        this.counterparts = counterparts;
 762    }
 763
 764    public List<MucOptions.User> getCounterparts() {
 765        return this.counterparts;
 766    }
 767
 768    @Override
 769    public int getAvatarBackgroundColor() {
 770        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 771            return Color.TRANSPARENT;
 772        } else {
 773            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 774        }
 775    }
 776
 777    @Override
 778    public String getAvatarName() {
 779        return UIHelper.getMessageDisplayName(this);
 780    }
 781
 782    public boolean isOOb() {
 783        return oob || getFileParams().url != null;
 784    }
 785
 786    public static class MergeSeparator {
 787    }
 788
 789    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 790        final Element html = getHtml();
 791        if (html == null || Build.VERSION.SDK_INT < 24) {
 792            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
 793        } else {
 794            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
 795                MessageUtils.filterLtrRtl(html.toString()).trim(),
 796                Html.FROM_HTML_MODE_COMPACT,
 797                (source) -> {
 798                   try {
 799                       if (thumbnailer == null) return fallbackImg;
 800                       Cid cid = BobTransfer.cid(new URI(source));
 801                       if (cid == null) return fallbackImg;
 802                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
 803                       if (thumbnail == null) return fallbackImg;
 804                       return thumbnail;
 805                   } catch (final URISyntaxException e) {
 806                       return fallbackImg;
 807                   }
 808                },
 809                (opening, tag, output, xmlReader) -> {}
 810            ));
 811
 812            // https://stackoverflow.com/a/10187511/8611
 813            int i = spannable.length();
 814            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
 815            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
 816        }
 817    }
 818
 819    public SpannableStringBuilder getMergedBody() {
 820        return getMergedBody(null, null);
 821    }
 822
 823    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 824        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
 825        Message current = this;
 826        while (current.mergeable(current.next())) {
 827            current = current.next();
 828            if (current == null) {
 829                break;
 830            }
 831            body.append("\n\n");
 832            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 833                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 834            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
 835        }
 836        return body;
 837    }
 838
 839    public boolean hasMeCommand() {
 840        return this.body.trim().startsWith(ME_COMMAND);
 841    }
 842
 843    public int getMergedStatus() {
 844        int status = this.status;
 845        Message current = this;
 846        while (current.mergeable(current.next())) {
 847            current = current.next();
 848            if (current == null) {
 849                break;
 850            }
 851            status = current.status;
 852        }
 853        return status;
 854    }
 855
 856    public long getMergedTimeSent() {
 857        long time = this.timeSent;
 858        Message current = this;
 859        while (current.mergeable(current.next())) {
 860            current = current.next();
 861            if (current == null) {
 862                break;
 863            }
 864            time = current.timeSent;
 865        }
 866        return time;
 867    }
 868
 869    public boolean wasMergedIntoPrevious() {
 870        Message prev = this.prev();
 871        return prev != null && prev.mergeable(this);
 872    }
 873
 874    public boolean trusted() {
 875        Contact contact = this.getContact();
 876        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
 877    }
 878
 879    public boolean fixCounterpart() {
 880        final Presences presences = conversation.getContact().getPresences();
 881        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
 882            return true;
 883        } else if (presences.size() >= 1) {
 884            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
 885            return true;
 886        } else {
 887            counterpart = null;
 888            return false;
 889        }
 890    }
 891
 892    public void setUuid(String uuid) {
 893        this.uuid = uuid;
 894    }
 895
 896    public String getEditedId() {
 897        if (edits.size() > 0) {
 898            return edits.get(edits.size() - 1).getEditedId();
 899        } else {
 900            throw new IllegalStateException("Attempting to store unedited message");
 901        }
 902    }
 903
 904    public String getEditedIdWireFormat() {
 905        if (edits.size() > 0) {
 906            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
 907        } else {
 908            throw new IllegalStateException("Attempting to store unedited message");
 909        }
 910    }
 911
 912    public URI getOob() {
 913        final String url = getFileParams().url;
 914        try {
 915            return url == null ? null : new URI(url);
 916        } catch (final URISyntaxException e) {
 917            return null;
 918        }
 919    }
 920
 921    public void addPayload(Element el) {
 922        this.payloads.add(el);
 923    }
 924
 925    public Element getHtml() {
 926        if (this.payloads == null) return null;
 927
 928        for (Element el : this.payloads) {
 929            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
 930                return el.getChildren().get(0);
 931            }
 932        }
 933
 934        return null;
 935    }
 936
 937    public List<Element> getCommands() {
 938        if (this.payloads == null) return null;
 939
 940        for (Element el : this.payloads) {
 941            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
 942                return el.getChildren();
 943            }
 944        }
 945
 946        return null;
 947    }
 948
 949    public String getMimeType() {
 950        String extension;
 951        if (relativeFilePath != null) {
 952            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
 953        } else {
 954            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
 955            if (url == null) {
 956                return null;
 957            }
 958            extension = MimeUtils.extractRelevantExtension(url);
 959        }
 960        return MimeUtils.guessMimeTypeFromExtension(extension);
 961    }
 962
 963    public synchronized boolean treatAsDownloadable() {
 964        if (treatAsDownloadable == null) {
 965            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
 966        }
 967        return treatAsDownloadable;
 968    }
 969
 970    public synchronized boolean bodyIsOnlyEmojis() {
 971        if (isEmojisOnly == null) {
 972            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
 973        }
 974        return isEmojisOnly;
 975    }
 976
 977    public synchronized boolean isGeoUri() {
 978        if (isGeoUri == null) {
 979            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
 980        }
 981        return isGeoUri;
 982    }
 983
 984    public synchronized void resetFileParams() {
 985        this.fileParams = null;
 986    }
 987
 988    public synchronized void setFileParams(FileParams fileParams) {
 989        this.fileParams = fileParams;
 990    }
 991
 992    public synchronized FileParams getFileParams() {
 993        if (fileParams == null) {
 994            fileParams = new FileParams(oob ? this.body : "");
 995            if (this.transferable != null) {
 996                fileParams.size = this.transferable.getFileSize();
 997            }
 998        }
 999        return fileParams;
1000    }
1001
1002    private static int parseInt(String value) {
1003        try {
1004            return Integer.parseInt(value);
1005        } catch (NumberFormatException e) {
1006            return 0;
1007        }
1008    }
1009
1010    public void untie() {
1011        this.mNextMessage = null;
1012        this.mPreviousMessage = null;
1013    }
1014
1015    public boolean isPrivateMessage() {
1016        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1017    }
1018
1019    public boolean isFileOrImage() {
1020        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1021    }
1022
1023
1024    public boolean isTypeText() {
1025        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1026    }
1027
1028    public boolean hasFileOnRemoteHost() {
1029        return isFileOrImage() && getFileParams().url != null;
1030    }
1031
1032    public boolean needsUploading() {
1033        return isFileOrImage() && getFileParams().url == null;
1034    }
1035
1036    public static class FileParams {
1037        public String url;
1038        public Long size = null;
1039        public int width = 0;
1040        public int height = 0;
1041        public int runtime = 0;
1042
1043        public FileParams() { }
1044
1045        public FileParams(Element el) {
1046            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1047                this.url = el.findChildContent("url", Namespace.OOB);
1048            }
1049            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1050                final String refUri = el.getAttribute("uri");
1051                if (refUri != null) url = refUri;
1052                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1053                if (mediaSharing != null) {
1054                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1055                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1056                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1057                    if (file != null) {
1058                        String sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:5");
1059                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:4");
1060                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:3");
1061                        if (sizeS != null) size = new Long(sizeS);
1062                    }
1063
1064                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1065                    if (sources != null) {
1066                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1067                        if (ref != null) url = ref.getAttribute("uri");
1068                    }
1069                }
1070            }
1071        }
1072
1073        public FileParams(String ser) {
1074            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1075            switch (parts.length) {
1076                case 1:
1077                    try {
1078                        this.size = Long.parseLong(parts[0]);
1079                    } catch (final NumberFormatException e) {
1080                        this.url = URL.tryParse(parts[0]);
1081                    }
1082                    break;
1083                case 5:
1084                    this.runtime = parseInt(parts[4]);
1085                case 4:
1086                    this.width = parseInt(parts[2]);
1087                    this.height = parseInt(parts[3]);
1088                case 2:
1089                    this.url = URL.tryParse(parts[0]);
1090                    this.size = Longs.tryParse(parts[1]);
1091                    break;
1092                case 3:
1093                    this.size = Longs.tryParse(parts[0]);
1094                    this.width = parseInt(parts[1]);
1095                    this.height = parseInt(parts[2]);
1096                    break;
1097            }
1098        }
1099
1100        public long getSize() {
1101            return size == null ? 0 : size;
1102        }
1103
1104        public String toString() {
1105            final StringBuilder builder = new StringBuilder();
1106            if (url != null) builder.append(url);
1107            if (size != null) builder.append('|').append(size.toString());
1108            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1109            if (height > 0 || runtime > 0) builder.append('|').append(height);
1110            if (runtime > 0) builder.append('|').append(runtime);
1111            return builder.toString();
1112        }
1113    }
1114
1115    public void setFingerprint(String fingerprint) {
1116        this.axolotlFingerprint = fingerprint;
1117    }
1118
1119    public String getFingerprint() {
1120        return axolotlFingerprint;
1121    }
1122
1123    public boolean isTrusted() {
1124        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1125        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1126        return s != null && s.isTrusted();
1127    }
1128
1129    private int getPreviousEncryption() {
1130        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1131            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1132                continue;
1133            }
1134            return iterator.getEncryption();
1135        }
1136        return ENCRYPTION_NONE;
1137    }
1138
1139    private int getNextEncryption() {
1140        if (this.conversation instanceof Conversation) {
1141            Conversation conversation = (Conversation) this.conversation;
1142            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1143                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1144                    continue;
1145                }
1146                return iterator.getEncryption();
1147            }
1148            return conversation.getNextEncryption();
1149        } else {
1150            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1151        }
1152    }
1153
1154    public boolean isValidInSession() {
1155        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1156        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1157
1158        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1159                || futureEncryption == ENCRYPTION_NONE
1160                || pastEncryption != futureEncryption;
1161
1162        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1163    }
1164
1165    private static int getCleanedEncryption(int encryption) {
1166        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1167            return ENCRYPTION_PGP;
1168        }
1169        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1170            return ENCRYPTION_AXOLOTL;
1171        }
1172        return encryption;
1173    }
1174
1175    public static boolean configurePrivateMessage(final Message message) {
1176        return configurePrivateMessage(message, false);
1177    }
1178
1179    public static boolean configurePrivateFileMessage(final Message message) {
1180        return configurePrivateMessage(message, true);
1181    }
1182
1183    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1184        final Conversation conversation;
1185        if (message.conversation instanceof Conversation) {
1186            conversation = (Conversation) message.conversation;
1187        } else {
1188            return false;
1189        }
1190        if (conversation.getMode() == Conversation.MODE_MULTI) {
1191            final Jid nextCounterpart = conversation.getNextCounterpart();
1192            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1193        }
1194        return false;
1195    }
1196
1197    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1198        final Conversation conversation;
1199        if (message.conversation instanceof Conversation) {
1200            conversation = (Conversation) message.conversation;
1201        } else {
1202            return false;
1203        }
1204        return configurePrivateMessage(conversation, message, counterpart, false);
1205    }
1206
1207    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1208        if (counterpart == null) {
1209            return false;
1210        }
1211        message.setCounterpart(counterpart);
1212        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1213        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1214        return true;
1215    }
1216}