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 static class MergeSeparator {
 733    }
 734
 735    public SpannableStringBuilder getMergedBody() {
 736        SpannableStringBuilder body = new SpannableStringBuilder(MessageUtils.filterLtrRtl(this.body).trim());
 737        Message current = this;
 738        while (current.mergeable(current.next())) {
 739            current = current.next();
 740            if (current == null) {
 741                break;
 742            }
 743            body.append("\n\n");
 744            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 745                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 746            body.append(MessageUtils.filterLtrRtl(current.getBody()).trim());
 747        }
 748        return body;
 749    }
 750
 751    public boolean hasMeCommand() {
 752        return this.body.trim().startsWith(ME_COMMAND);
 753    }
 754
 755    public int getMergedStatus() {
 756        int status = this.status;
 757        Message current = this;
 758        while (current.mergeable(current.next())) {
 759            current = current.next();
 760            if (current == null) {
 761                break;
 762            }
 763            status = current.status;
 764        }
 765        return status;
 766    }
 767
 768    public long getMergedTimeSent() {
 769        long time = this.timeSent;
 770        Message current = this;
 771        while (current.mergeable(current.next())) {
 772            current = current.next();
 773            if (current == null) {
 774                break;
 775            }
 776            time = current.timeSent;
 777        }
 778        return time;
 779    }
 780
 781    public boolean wasMergedIntoPrevious() {
 782        Message prev = this.prev();
 783        return prev != null && prev.mergeable(this);
 784    }
 785
 786    public boolean trusted() {
 787        Contact contact = this.getContact();
 788        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
 789    }
 790
 791    public boolean fixCounterpart() {
 792        final Presences presences = conversation.getContact().getPresences();
 793        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
 794            return true;
 795        } else if (presences.size() >= 1) {
 796            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
 797            return true;
 798        } else {
 799            counterpart = null;
 800            return false;
 801        }
 802    }
 803
 804    public void setUuid(String uuid) {
 805        this.uuid = uuid;
 806    }
 807
 808    public String getEditedId() {
 809        if (edits.size() > 0) {
 810            return edits.get(edits.size() - 1).getEditedId();
 811        } else {
 812            throw new IllegalStateException("Attempting to store unedited message");
 813        }
 814    }
 815
 816    public String getEditedIdWireFormat() {
 817        if (edits.size() > 0) {
 818            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
 819        } else {
 820            throw new IllegalStateException("Attempting to store unedited message");
 821        }
 822    }
 823
 824    public void setOob(boolean isOob) {
 825        this.oob = isOob;
 826    }
 827
 828    public String getMimeType() {
 829        String extension;
 830        if (relativeFilePath != null) {
 831            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
 832        } else {
 833            final String url = URL.tryParse(body.split("\n")[0]);
 834            if (url == null) {
 835                return null;
 836            }
 837            extension = MimeUtils.extractRelevantExtension(url);
 838        }
 839        return MimeUtils.guessMimeTypeFromExtension(extension);
 840    }
 841
 842    public synchronized boolean treatAsDownloadable() {
 843        if (treatAsDownloadable == null) {
 844            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, this.oob);
 845        }
 846        return treatAsDownloadable;
 847    }
 848
 849    public synchronized boolean bodyIsOnlyEmojis() {
 850        if (isEmojisOnly == null) {
 851            isEmojisOnly = Emoticons.isOnlyEmoji(body.replaceAll("\\s", ""));
 852        }
 853        return isEmojisOnly;
 854    }
 855
 856    public synchronized boolean isGeoUri() {
 857        if (isGeoUri == null) {
 858            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
 859        }
 860        return isGeoUri;
 861    }
 862
 863    public synchronized void resetFileParams() {
 864        this.fileParams = null;
 865    }
 866
 867    public synchronized FileParams getFileParams() {
 868        if (fileParams == null) {
 869            fileParams = new FileParams();
 870            if (this.transferable != null) {
 871                fileParams.size = this.transferable.getFileSize();
 872            }
 873            final String[] parts = body == null ? new String[0] : body.split("\\|");
 874            switch (parts.length) {
 875                case 1:
 876                    try {
 877                        fileParams.size = Long.parseLong(parts[0]);
 878                    } catch (final NumberFormatException e) {
 879                        fileParams.url = URL.tryParse(parts[0]);
 880                    }
 881                    break;
 882                case 5:
 883                    fileParams.runtime = parseInt(parts[4]);
 884                case 4:
 885                    fileParams.width = parseInt(parts[2]);
 886                    fileParams.height = parseInt(parts[3]);
 887                case 2:
 888                    fileParams.url = URL.tryParse(parts[0]);
 889                    fileParams.size = Longs.tryParse(parts[1]);
 890                    break;
 891                case 3:
 892                    fileParams.size = Longs.tryParse(parts[0]);
 893                    fileParams.width = parseInt(parts[1]);
 894                    fileParams.height = parseInt(parts[2]);
 895                    break;
 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 long getSize() {
 943            return size == null ? 0 : size;
 944        }
 945    }
 946
 947    public void setFingerprint(String fingerprint) {
 948        this.axolotlFingerprint = fingerprint;
 949    }
 950
 951    public String getFingerprint() {
 952        return axolotlFingerprint;
 953    }
 954
 955    public boolean isTrusted() {
 956        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
 957        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
 958        return s != null && s.isTrusted();
 959    }
 960
 961    private int getPreviousEncryption() {
 962        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
 963            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
 964                continue;
 965            }
 966            return iterator.getEncryption();
 967        }
 968        return ENCRYPTION_NONE;
 969    }
 970
 971    private int getNextEncryption() {
 972        if (this.conversation instanceof Conversation) {
 973            Conversation conversation = (Conversation) this.conversation;
 974            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
 975                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
 976                    continue;
 977                }
 978                return iterator.getEncryption();
 979            }
 980            return conversation.getNextEncryption();
 981        } else {
 982            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
 983        }
 984    }
 985
 986    public boolean isValidInSession() {
 987        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
 988        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
 989
 990        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
 991                || futureEncryption == ENCRYPTION_NONE
 992                || pastEncryption != futureEncryption;
 993
 994        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
 995    }
 996
 997    private static int getCleanedEncryption(int encryption) {
 998        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
 999            return ENCRYPTION_PGP;
1000        }
1001        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1002            return ENCRYPTION_AXOLOTL;
1003        }
1004        return encryption;
1005    }
1006
1007    public static boolean configurePrivateMessage(final Message message) {
1008        return configurePrivateMessage(message, false);
1009    }
1010
1011    public static boolean configurePrivateFileMessage(final Message message) {
1012        return configurePrivateMessage(message, true);
1013    }
1014
1015    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1016        final Conversation conversation;
1017        if (message.conversation instanceof Conversation) {
1018            conversation = (Conversation) message.conversation;
1019        } else {
1020            return false;
1021        }
1022        if (conversation.getMode() == Conversation.MODE_MULTI) {
1023            final Jid nextCounterpart = conversation.getNextCounterpart();
1024            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1025        }
1026        return false;
1027    }
1028
1029    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1030        final Conversation conversation;
1031        if (message.conversation instanceof Conversation) {
1032            conversation = (Conversation) message.conversation;
1033        } else {
1034            return false;
1035        }
1036        return configurePrivateMessage(conversation, message, counterpart, false);
1037    }
1038
1039    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1040        if (counterpart == null) {
1041            return false;
1042        }
1043        message.setCounterpart(counterpart);
1044        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1045        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1046        return true;
1047    }
1048}