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