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