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