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