Message.java

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