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(Cursor cursor, 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    boolean remoteMsgIdMatchInEdit(String id) {
 493        for (Edit edit : this.edits) {
 494            if (id.equals(edit.getEditedId())) {
 495                return true;
 496            }
 497        }
 498        return false;
 499    }
 500
 501    public String getBodyLanguage() {
 502        return this.bodyLanguage;
 503    }
 504
 505    public void setBodyLanguage(String language) {
 506        this.bodyLanguage = language;
 507    }
 508
 509    public boolean edited() {
 510        return this.edits.size() > 0;
 511    }
 512
 513    public void setTrueCounterpart(Jid trueCounterpart) {
 514        this.trueCounterpart = trueCounterpart;
 515    }
 516
 517    public Jid getTrueCounterpart() {
 518        return this.trueCounterpart;
 519    }
 520
 521    public Transferable getTransferable() {
 522        return this.transferable;
 523    }
 524
 525    public synchronized void setTransferable(Transferable transferable) {
 526        this.fileParams = null;
 527        this.transferable = transferable;
 528    }
 529
 530    public boolean addReadByMarker(final ReadByMarker readByMarker) {
 531        if (readByMarker.getRealJid() != null) {
 532            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 533                return false;
 534            }
 535        } else if (readByMarker.getFullJid() != null) {
 536            if (readByMarker.getFullJid().equals(counterpart)) {
 537                return false;
 538            }
 539        }
 540        if (this.readByMarkers.add(readByMarker)) {
 541            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 542                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 543                while (iterator.hasNext()) {
 544                    ReadByMarker marker = iterator.next();
 545                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 546                        iterator.remove();
 547                    }
 548                }
 549            }
 550            return true;
 551        } else {
 552            return false;
 553        }
 554    }
 555
 556    public Set<ReadByMarker> getReadByMarkers() {
 557        return ImmutableSet.copyOf(this.readByMarkers);
 558    }
 559
 560    public Set<Jid> getReadyByTrue() {
 561        return ImmutableSet.copyOf(
 562                Collections2.transform(
 563                        Collections2.filter(this.readByMarkers, m -> m.getRealJid() != null),
 564                        ReadByMarker::getRealJid));
 565    }
 566
 567    boolean similar(Message message) {
 568        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 569            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 570        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 571            return true;
 572        } else if (this.body == null || this.counterpart == null) {
 573            return false;
 574        } else {
 575            String body, otherBody;
 576            if (this.hasFileOnRemoteHost()) {
 577                body = getFileParams().url;
 578                otherBody = message.body == null ? null : message.body.trim();
 579            } else {
 580                body = this.body;
 581                otherBody = message.body;
 582            }
 583            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 584            if (message.getRemoteMsgId() != null) {
 585                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 586                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 587                    return true;
 588                }
 589                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 590                        && matchingCounterpart
 591                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 592            } else {
 593                return this.remoteMsgId == null
 594                        && matchingCounterpart
 595                        && body.equals(otherBody)
 596                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 597            }
 598        }
 599    }
 600
 601    public Message next() {
 602        if (this.conversation instanceof Conversation) {
 603            final Conversation conversation = (Conversation) this.conversation;
 604            synchronized (conversation.messages) {
 605                if (this.mNextMessage == null) {
 606                    int index = conversation.messages.indexOf(this);
 607                    if (index < 0 || index >= conversation.messages.size() - 1) {
 608                        this.mNextMessage = null;
 609                    } else {
 610                        this.mNextMessage = conversation.messages.get(index + 1);
 611                    }
 612                }
 613                return this.mNextMessage;
 614            }
 615        } else {
 616            throw new AssertionError("Calling next should be disabled for stubs");
 617        }
 618    }
 619
 620    public Message prev() {
 621        if (this.conversation instanceof Conversation) {
 622            final Conversation conversation = (Conversation) this.conversation;
 623            synchronized (conversation.messages) {
 624                if (this.mPreviousMessage == null) {
 625                    int index = conversation.messages.indexOf(this);
 626                    if (index <= 0 || index > conversation.messages.size()) {
 627                        this.mPreviousMessage = null;
 628                    } else {
 629                        this.mPreviousMessage = conversation.messages.get(index - 1);
 630                    }
 631                }
 632            }
 633            return this.mPreviousMessage;
 634        } else {
 635            throw new AssertionError("Calling prev should be disabled for stubs");
 636        }
 637    }
 638
 639    public boolean isLastCorrectableMessage() {
 640        Message next = next();
 641        while (next != null) {
 642            if (next.isEditable()) {
 643                return false;
 644            }
 645            next = next.next();
 646        }
 647        return isEditable();
 648    }
 649
 650    public boolean isEditable() {
 651        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 652    }
 653
 654    public boolean mergeable(final Message message) {
 655        return message != null &&
 656                (message.getType() == Message.TYPE_TEXT &&
 657                        this.getTransferable() == null &&
 658                        message.getTransferable() == null &&
 659                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 660                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 661                        this.getType() == message.getType() &&
 662                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 663                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 664                        this.getCounterpart() != null &&
 665                        this.getCounterpart().equals(message.getCounterpart()) &&
 666                        this.edited() == message.edited() &&
 667                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 668                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 669                        !message.isGeoUri() &&
 670                        !this.isGeoUri() &&
 671                        !message.isOOb() &&
 672                        !this.isOOb() &&
 673                        !message.treatAsDownloadable() &&
 674                        !this.treatAsDownloadable() &&
 675                        !message.hasMeCommand() &&
 676                        !this.hasMeCommand() &&
 677                        !this.bodyIsOnlyEmojis() &&
 678                        !message.bodyIsOnlyEmojis() &&
 679                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 680                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 681                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 682                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 683                );
 684    }
 685
 686    private static boolean isStatusMergeable(int a, int b) {
 687        return a == b || (
 688                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 689                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 690                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 691                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 692                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 693        );
 694    }
 695
 696    private static boolean isEncryptionMergeable(final int a, final int b) {
 697        return a == b
 698                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 699                        .contains(a);
 700    }
 701
 702    public void setCounterparts(List<MucOptions.User> counterparts) {
 703        this.counterparts = counterparts;
 704    }
 705
 706    public List<MucOptions.User> getCounterparts() {
 707        return this.counterparts;
 708    }
 709
 710    @Override
 711    public int getAvatarBackgroundColor() {
 712        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 713            return Color.TRANSPARENT;
 714        } else {
 715            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 716        }
 717    }
 718
 719    @Override
 720    public String getAvatarName() {
 721        return UIHelper.getMessageDisplayName(this);
 722    }
 723
 724    public boolean isOOb() {
 725        return oob;
 726    }
 727
 728    public void setOccupantId(final String id) {
 729        this.occupantId = id;
 730    }
 731
 732    public String getOccupantId() {
 733        return this.occupantId;
 734    }
 735
 736    public Collection<Reaction> getReactions() {
 737        return this.reactions;
 738    }
 739
 740    public Reaction.Aggregated getAggregatedReactions() {
 741        return Reaction.aggregated(this.reactions);
 742    }
 743
 744    public void setReactions(final Collection<Reaction> reactions) {
 745        this.reactions = reactions;
 746    }
 747
 748    public static class MergeSeparator {
 749    }
 750
 751    public SpannableStringBuilder getMergedBody() {
 752        SpannableStringBuilder body = new SpannableStringBuilder(MessageUtils.filterLtrRtl(this.body).trim());
 753        Message current = this;
 754        while (current.mergeable(current.next())) {
 755            current = current.next();
 756            if (current == null) {
 757                break;
 758            }
 759            body.append("\n\n");
 760            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 761                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 762            body.append(MessageUtils.filterLtrRtl(current.getBody()).trim());
 763        }
 764        return body;
 765    }
 766
 767    public boolean hasMeCommand() {
 768        return this.body.trim().startsWith(ME_COMMAND);
 769    }
 770
 771    public int getMergedStatus() {
 772        int status = this.status;
 773        Message current = this;
 774        while (current.mergeable(current.next())) {
 775            current = current.next();
 776            if (current == null) {
 777                break;
 778            }
 779            status = current.status;
 780        }
 781        return status;
 782    }
 783
 784    public long getMergedTimeSent() {
 785        long time = this.timeSent;
 786        Message current = this;
 787        while (current.mergeable(current.next())) {
 788            current = current.next();
 789            if (current == null) {
 790                break;
 791            }
 792            time = current.timeSent;
 793        }
 794        return time;
 795    }
 796
 797    public boolean wasMergedIntoPrevious() {
 798        Message prev = this.prev();
 799        return prev != null && prev.mergeable(this);
 800    }
 801
 802    public boolean trusted() {
 803        Contact contact = this.getContact();
 804        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
 805    }
 806
 807    public boolean fixCounterpart() {
 808        final Presences presences = conversation.getContact().getPresences();
 809        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
 810            return true;
 811        } else if (presences.size() >= 1) {
 812            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
 813            return true;
 814        } else {
 815            counterpart = null;
 816            return false;
 817        }
 818    }
 819
 820    public void setUuid(String uuid) {
 821        this.uuid = uuid;
 822    }
 823
 824    public String getEditedId() {
 825        if (edits.size() > 0) {
 826            return edits.get(edits.size() - 1).getEditedId();
 827        } else {
 828            throw new IllegalStateException("Attempting to store unedited message");
 829        }
 830    }
 831
 832    public String getEditedIdWireFormat() {
 833        if (edits.size() > 0) {
 834            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
 835        } else {
 836            throw new IllegalStateException("Attempting to store unedited message");
 837        }
 838    }
 839
 840    public void setOob(boolean isOob) {
 841        this.oob = isOob;
 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(body.split("\n")[0]);
 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, this.oob);
 861        }
 862        return treatAsDownloadable;
 863    }
 864
 865    public synchronized boolean bodyIsOnlyEmojis() {
 866        if (isEmojisOnly == null) {
 867            isEmojisOnly = Emoticons.isOnlyEmoji(body.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 FileParams getFileParams() {
 884        if (fileParams == null) {
 885            fileParams = new FileParams();
 886            if (this.transferable != null) {
 887                fileParams.size = this.transferable.getFileSize();
 888            }
 889            final String[] parts = body == null ? new String[0] : body.split("\\|");
 890            switch (parts.length) {
 891                case 1:
 892                    try {
 893                        fileParams.size = Long.parseLong(parts[0]);
 894                    } catch (final NumberFormatException e) {
 895                        fileParams.url = URL.tryParse(parts[0]);
 896                    }
 897                    break;
 898                case 5:
 899                    fileParams.runtime = parseInt(parts[4]);
 900                case 4:
 901                    fileParams.width = parseInt(parts[2]);
 902                    fileParams.height = parseInt(parts[3]);
 903                case 2:
 904                    fileParams.url = URL.tryParse(parts[0]);
 905                    fileParams.size = Longs.tryParse(parts[1]);
 906                    break;
 907                case 3:
 908                    fileParams.size = Longs.tryParse(parts[0]);
 909                    fileParams.width = parseInt(parts[1]);
 910                    fileParams.height = parseInt(parts[2]);
 911                    break;
 912            }
 913        }
 914        return fileParams;
 915    }
 916
 917    private static int parseInt(String value) {
 918        try {
 919            return Integer.parseInt(value);
 920        } catch (NumberFormatException e) {
 921            return 0;
 922        }
 923    }
 924
 925    public void untie() {
 926        this.mNextMessage = null;
 927        this.mPreviousMessage = null;
 928    }
 929
 930    public boolean isPrivateMessage() {
 931        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
 932    }
 933
 934    public boolean isFileOrImage() {
 935        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
 936    }
 937
 938
 939    public boolean isTypeText() {
 940        return type == TYPE_TEXT || type == TYPE_PRIVATE;
 941    }
 942
 943    public boolean hasFileOnRemoteHost() {
 944        return isFileOrImage() && getFileParams().url != null;
 945    }
 946
 947    public boolean needsUploading() {
 948        return isFileOrImage() && getFileParams().url == null;
 949    }
 950
 951    public static class FileParams {
 952        public String url;
 953        public Long size = null;
 954        public int width = 0;
 955        public int height = 0;
 956        public int runtime = 0;
 957
 958        public long getSize() {
 959            return size == null ? 0 : size;
 960        }
 961    }
 962
 963    public void setFingerprint(String fingerprint) {
 964        this.axolotlFingerprint = fingerprint;
 965    }
 966
 967    public String getFingerprint() {
 968        return axolotlFingerprint;
 969    }
 970
 971    public boolean isTrusted() {
 972        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
 973        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
 974        return s != null && s.isTrusted();
 975    }
 976
 977    private int getPreviousEncryption() {
 978        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
 979            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
 980                continue;
 981            }
 982            return iterator.getEncryption();
 983        }
 984        return ENCRYPTION_NONE;
 985    }
 986
 987    private int getNextEncryption() {
 988        if (this.conversation instanceof Conversation) {
 989            Conversation conversation = (Conversation) this.conversation;
 990            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
 991                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
 992                    continue;
 993                }
 994                return iterator.getEncryption();
 995            }
 996            return conversation.getNextEncryption();
 997        } else {
 998            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
 999        }
1000    }
1001
1002    public boolean isValidInSession() {
1003        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1004        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1005
1006        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1007                || futureEncryption == ENCRYPTION_NONE
1008                || pastEncryption != futureEncryption;
1009
1010        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1011    }
1012
1013    private static int getCleanedEncryption(int encryption) {
1014        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1015            return ENCRYPTION_PGP;
1016        }
1017        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1018            return ENCRYPTION_AXOLOTL;
1019        }
1020        return encryption;
1021    }
1022
1023    public static boolean configurePrivateMessage(final Message message) {
1024        return configurePrivateMessage(message, false);
1025    }
1026
1027    public static boolean configurePrivateFileMessage(final Message message) {
1028        return configurePrivateMessage(message, true);
1029    }
1030
1031    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1032        final Conversation conversation;
1033        if (message.conversation instanceof Conversation) {
1034            conversation = (Conversation) message.conversation;
1035        } else {
1036            return false;
1037        }
1038        if (conversation.getMode() == Conversation.MODE_MULTI) {
1039            final Jid nextCounterpart = conversation.getNextCounterpart();
1040            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1041        }
1042        return false;
1043    }
1044
1045    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1046        final Conversation conversation;
1047        if (message.conversation instanceof Conversation) {
1048            conversation = (Conversation) message.conversation;
1049        } else {
1050            return false;
1051        }
1052        return configurePrivateMessage(conversation, message, counterpart, false);
1053    }
1054
1055    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1056        if (counterpart == null) {
1057            return false;
1058        }
1059        message.setCounterpart(counterpart);
1060        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1061        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1062        return true;
1063    }
1064}