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