Message.java

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