Message.java

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