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 Element getThread() {
 422        if (this.payloads == null) return null;
 423
 424        for (Element el : this.payloads) {
 425            if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
 426                return el;
 427            }
 428        }
 429
 430        return null;
 431    }
 432
 433    public void setThread(Element thread) {
 434        payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
 435        addPayload(thread);
 436    }
 437
 438    public void setMucUser(MucOptions.User user) {
 439        this.user = new WeakReference<>(user);
 440    }
 441
 442    public boolean sameMucUser(Message otherMessage) {
 443        final MucOptions.User thisUser = this.user == null ? null : this.user.get();
 444        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
 445        return thisUser != null && thisUser == otherUser;
 446    }
 447
 448    public String getErrorMessage() {
 449        return errorMessage;
 450    }
 451
 452    public boolean setErrorMessage(String message) {
 453        boolean changed = (message != null && !message.equals(errorMessage))
 454                || (message == null && errorMessage != null);
 455        this.errorMessage = message;
 456        return changed;
 457    }
 458
 459    public long getTimeReceived() {
 460        return timeReceived;
 461    }
 462
 463    public long getTimeSent() {
 464        return timeSent;
 465    }
 466
 467    public int getEncryption() {
 468        return encryption;
 469    }
 470
 471    public void setEncryption(int encryption) {
 472        this.encryption = encryption;
 473    }
 474
 475    public int getStatus() {
 476        return status;
 477    }
 478
 479    public void setStatus(int status) {
 480        this.status = status;
 481    }
 482
 483    public String getRelativeFilePath() {
 484        return this.relativeFilePath;
 485    }
 486
 487    public void setRelativeFilePath(String path) {
 488        this.relativeFilePath = path;
 489    }
 490
 491    public String getRemoteMsgId() {
 492        return this.remoteMsgId;
 493    }
 494
 495    public void setRemoteMsgId(String id) {
 496        this.remoteMsgId = id;
 497    }
 498
 499    public String getServerMsgId() {
 500        return this.serverMsgId;
 501    }
 502
 503    public void setServerMsgId(String id) {
 504        this.serverMsgId = id;
 505    }
 506
 507    public boolean isRead() {
 508        return this.read;
 509    }
 510
 511    public boolean isDeleted() {
 512        return this.deleted;
 513    }
 514
 515    public void setDeleted(boolean deleted) {
 516        this.deleted = deleted;
 517    }
 518
 519    public void markRead() {
 520        this.read = true;
 521    }
 522
 523    public void markUnread() {
 524        this.read = false;
 525    }
 526
 527    public void setTime(long time) {
 528        this.timeSent = time;
 529    }
 530
 531    public void setTimeReceived(long time) {
 532        this.timeReceived = time;
 533    }
 534
 535    public String getEncryptedBody() {
 536        return this.encryptedBody;
 537    }
 538
 539    public void setEncryptedBody(String body) {
 540        this.encryptedBody = body;
 541    }
 542
 543    public int getType() {
 544        return this.type;
 545    }
 546
 547    public void setType(int type) {
 548        this.type = type;
 549    }
 550
 551    public boolean isCarbon() {
 552        return carbon;
 553    }
 554
 555    public void setCarbon(boolean carbon) {
 556        this.carbon = carbon;
 557    }
 558
 559    public void putEdited(String edited, String serverMsgId) {
 560        final Edit edit = new Edit(edited, serverMsgId);
 561        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
 562            this.edits.add(edit);
 563        }
 564    }
 565
 566    boolean remoteMsgIdMatchInEdit(String id) {
 567        for (Edit edit : this.edits) {
 568            if (id.equals(edit.getEditedId())) {
 569                return true;
 570            }
 571        }
 572        return false;
 573    }
 574
 575    public String getBodyLanguage() {
 576        return this.bodyLanguage;
 577    }
 578
 579    public void setBodyLanguage(String language) {
 580        this.bodyLanguage = language;
 581    }
 582
 583    public boolean edited() {
 584        return this.edits.size() > 0;
 585    }
 586
 587    public void setTrueCounterpart(Jid trueCounterpart) {
 588        this.trueCounterpart = trueCounterpart;
 589    }
 590
 591    public Jid getTrueCounterpart() {
 592        return this.trueCounterpart;
 593    }
 594
 595    public Transferable getTransferable() {
 596        return this.transferable;
 597    }
 598
 599    public synchronized void setTransferable(Transferable transferable) {
 600        this.transferable = transferable;
 601    }
 602
 603    public boolean addReadByMarker(ReadByMarker readByMarker) {
 604        if (readByMarker.getRealJid() != null) {
 605            if (readByMarker.getRealJid().asBareJid().equals(trueCounterpart)) {
 606                return false;
 607            }
 608        } else if (readByMarker.getFullJid() != null) {
 609            if (readByMarker.getFullJid().equals(counterpart)) {
 610                return false;
 611            }
 612        }
 613        if (this.readByMarkers.add(readByMarker)) {
 614            if (readByMarker.getRealJid() != null && readByMarker.getFullJid() != null) {
 615                Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
 616                while (iterator.hasNext()) {
 617                    ReadByMarker marker = iterator.next();
 618                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
 619                        iterator.remove();
 620                    }
 621                }
 622            }
 623            return true;
 624        } else {
 625            return false;
 626        }
 627    }
 628
 629    public Set<ReadByMarker> getReadByMarkers() {
 630        return ImmutableSet.copyOf(this.readByMarkers);
 631    }
 632
 633    boolean similar(Message message) {
 634        if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
 635            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
 636        } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
 637            return true;
 638        } else if (this.body == null || this.counterpart == null) {
 639            return false;
 640        } else {
 641            String body, otherBody;
 642            if (this.hasFileOnRemoteHost()) {
 643                body = getFileParams().url;
 644                otherBody = message.body == null ? null : message.body.trim();
 645            } else {
 646                body = this.body;
 647                otherBody = message.body;
 648            }
 649            final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
 650            if (message.getRemoteMsgId() != null) {
 651                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
 652                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
 653                    return true;
 654                }
 655                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
 656                        && matchingCounterpart
 657                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
 658            } else {
 659                return this.remoteMsgId == null
 660                        && matchingCounterpart
 661                        && body.equals(otherBody)
 662                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
 663            }
 664        }
 665    }
 666
 667    public Message next() {
 668        if (this.conversation instanceof Conversation) {
 669            final Conversation conversation = (Conversation) this.conversation;
 670            synchronized (conversation.messages) {
 671                if (this.mNextMessage == null) {
 672                    int index = conversation.messages.indexOf(this);
 673                    if (index < 0 || index >= conversation.messages.size() - 1) {
 674                        this.mNextMessage = null;
 675                    } else {
 676                        this.mNextMessage = conversation.messages.get(index + 1);
 677                    }
 678                }
 679                return this.mNextMessage;
 680            }
 681        } else {
 682            throw new AssertionError("Calling next should be disabled for stubs");
 683        }
 684    }
 685
 686    public Message prev() {
 687        if (this.conversation instanceof Conversation) {
 688            final Conversation conversation = (Conversation) this.conversation;
 689            synchronized (conversation.messages) {
 690                if (this.mPreviousMessage == null) {
 691                    int index = conversation.messages.indexOf(this);
 692                    if (index <= 0 || index > conversation.messages.size()) {
 693                        this.mPreviousMessage = null;
 694                    } else {
 695                        this.mPreviousMessage = conversation.messages.get(index - 1);
 696                    }
 697                }
 698            }
 699            return this.mPreviousMessage;
 700        } else {
 701            throw new AssertionError("Calling prev should be disabled for stubs");
 702        }
 703    }
 704
 705    public boolean isLastCorrectableMessage() {
 706        Message next = next();
 707        while (next != null) {
 708            if (next.isEditable()) {
 709                return false;
 710            }
 711            next = next.next();
 712        }
 713        return isEditable();
 714    }
 715
 716    public boolean isEditable() {
 717        return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
 718    }
 719
 720    public boolean mergeable(final Message message) {
 721        return message != null &&
 722                (message.getType() == Message.TYPE_TEXT &&
 723                        this.getTransferable() == null &&
 724                        message.getTransferable() == null &&
 725                        message.getEncryption() != Message.ENCRYPTION_PGP &&
 726                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
 727                        this.getType() == message.getType() &&
 728                        this.getSubject() != null &&
 729                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
 730                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
 731                        this.getCounterpart() != null &&
 732                        this.getCounterpart().equals(message.getCounterpart()) &&
 733                        this.edited() == message.edited() &&
 734                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
 735                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
 736                        !message.isGeoUri() &&
 737                        !this.isGeoUri() &&
 738                        !message.isOOb() &&
 739                        !this.isOOb() &&
 740                        !message.treatAsDownloadable() &&
 741                        !this.treatAsDownloadable() &&
 742                        !message.hasMeCommand() &&
 743                        !this.hasMeCommand() &&
 744                        !this.bodyIsOnlyEmojis() &&
 745                        !message.bodyIsOnlyEmojis() &&
 746                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
 747                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
 748                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
 749                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
 750                );
 751    }
 752
 753    private static boolean isStatusMergeable(int a, int b) {
 754        return a == b || (
 755                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
 756                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
 757                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
 758                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
 759                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
 760        );
 761    }
 762
 763    private static boolean isEncryptionMergeable(final int a, final int b) {
 764        return a == b
 765                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
 766                        .contains(a);
 767    }
 768
 769    public void setCounterparts(List<MucOptions.User> counterparts) {
 770        this.counterparts = counterparts;
 771    }
 772
 773    public List<MucOptions.User> getCounterparts() {
 774        return this.counterparts;
 775    }
 776
 777    @Override
 778    public int getAvatarBackgroundColor() {
 779        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
 780            return Color.TRANSPARENT;
 781        } else {
 782            return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
 783        }
 784    }
 785
 786    @Override
 787    public String getAvatarName() {
 788        return UIHelper.getMessageDisplayName(this);
 789    }
 790
 791    public boolean isOOb() {
 792        return oob || getFileParams().url != null;
 793    }
 794
 795    public static class MergeSeparator {
 796    }
 797
 798    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 799        final Element html = getHtml();
 800        if (html == null || Build.VERSION.SDK_INT < 24) {
 801            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
 802        } else {
 803            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
 804                MessageUtils.filterLtrRtl(html.toString()).trim(),
 805                Html.FROM_HTML_MODE_COMPACT,
 806                (source) -> {
 807                   try {
 808                       if (thumbnailer == null) return fallbackImg;
 809                       Cid cid = BobTransfer.cid(new URI(source));
 810                       if (cid == null) return fallbackImg;
 811                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
 812                       if (thumbnail == null) return fallbackImg;
 813                       return thumbnail;
 814                   } catch (final URISyntaxException e) {
 815                       return fallbackImg;
 816                   }
 817                },
 818                (opening, tag, output, xmlReader) -> {}
 819            ));
 820
 821            // https://stackoverflow.com/a/10187511/8611
 822            int i = spannable.length();
 823            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
 824            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
 825        }
 826    }
 827
 828    public SpannableStringBuilder getMergedBody() {
 829        return getMergedBody(null, null);
 830    }
 831
 832    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
 833        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
 834        Message current = this;
 835        while (current.mergeable(current.next())) {
 836            current = current.next();
 837            if (current == null) {
 838                break;
 839            }
 840            body.append("\n\n");
 841            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
 842                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
 843            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
 844        }
 845        return body;
 846    }
 847
 848    public boolean hasMeCommand() {
 849        return this.body.trim().startsWith(ME_COMMAND);
 850    }
 851
 852    public int getMergedStatus() {
 853        int status = this.status;
 854        Message current = this;
 855        while (current.mergeable(current.next())) {
 856            current = current.next();
 857            if (current == null) {
 858                break;
 859            }
 860            status = current.status;
 861        }
 862        return status;
 863    }
 864
 865    public long getMergedTimeSent() {
 866        long time = this.timeSent;
 867        Message current = this;
 868        while (current.mergeable(current.next())) {
 869            current = current.next();
 870            if (current == null) {
 871                break;
 872            }
 873            time = current.timeSent;
 874        }
 875        return time;
 876    }
 877
 878    public boolean wasMergedIntoPrevious() {
 879        Message prev = this.prev();
 880        return prev != null && prev.mergeable(this);
 881    }
 882
 883    public boolean trusted() {
 884        Contact contact = this.getContact();
 885        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
 886    }
 887
 888    public boolean fixCounterpart() {
 889        final Presences presences = conversation.getContact().getPresences();
 890        if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
 891            return true;
 892        } else if (presences.size() >= 1) {
 893            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
 894            return true;
 895        } else {
 896            counterpart = null;
 897            return false;
 898        }
 899    }
 900
 901    public void setUuid(String uuid) {
 902        this.uuid = uuid;
 903    }
 904
 905    public String getEditedId() {
 906        if (edits.size() > 0) {
 907            return edits.get(edits.size() - 1).getEditedId();
 908        } else {
 909            throw new IllegalStateException("Attempting to store unedited message");
 910        }
 911    }
 912
 913    public String getEditedIdWireFormat() {
 914        if (edits.size() > 0) {
 915            return edits.get(Config.USE_LMC_VERSION_1_1 ? 0 : edits.size() - 1).getEditedId();
 916        } else {
 917            throw new IllegalStateException("Attempting to store unedited message");
 918        }
 919    }
 920
 921    public URI getOob() {
 922        final String url = getFileParams().url;
 923        try {
 924            return url == null ? null : new URI(url);
 925        } catch (final URISyntaxException e) {
 926            return null;
 927        }
 928    }
 929
 930    public void addPayload(Element el) {
 931        if (el == null) return;
 932
 933        this.payloads.add(el);
 934    }
 935
 936    public List<Element> getPayloads() {
 937       return new ArrayList<>(this.payloads);
 938    }
 939
 940    public Element getHtml() {
 941        if (this.payloads == null) return null;
 942
 943        for (Element el : this.payloads) {
 944            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
 945                return el.getChildren().get(0);
 946            }
 947        }
 948
 949        return null;
 950   }
 951
 952    public List<Element> getCommands() {
 953        if (this.payloads == null) return null;
 954
 955        for (Element el : this.payloads) {
 956            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
 957                return el.getChildren();
 958            }
 959        }
 960
 961        return null;
 962    }
 963
 964    public String getMimeType() {
 965        String extension;
 966        if (relativeFilePath != null) {
 967            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
 968        } else {
 969            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
 970            if (url == null) {
 971                return null;
 972            }
 973            extension = MimeUtils.extractRelevantExtension(url);
 974        }
 975        return MimeUtils.guessMimeTypeFromExtension(extension);
 976    }
 977
 978    public synchronized boolean treatAsDownloadable() {
 979        if (treatAsDownloadable == null) {
 980            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
 981        }
 982        return treatAsDownloadable;
 983    }
 984
 985    public synchronized boolean bodyIsOnlyEmojis() {
 986        if (isEmojisOnly == null) {
 987            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
 988        }
 989        return isEmojisOnly;
 990    }
 991
 992    public synchronized boolean isGeoUri() {
 993        if (isGeoUri == null) {
 994            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
 995        }
 996        return isGeoUri;
 997    }
 998
 999    public synchronized void resetFileParams() {
1000        this.fileParams = null;
1001    }
1002
1003    public synchronized void setFileParams(FileParams fileParams) {
1004        this.fileParams = fileParams;
1005    }
1006
1007    public synchronized FileParams getFileParams() {
1008        if (fileParams == null) {
1009            fileParams = new FileParams(oob ? this.body : "");
1010            if (this.transferable != null) {
1011                fileParams.size = this.transferable.getFileSize();
1012            }
1013        }
1014        return fileParams;
1015    }
1016
1017    private static int parseInt(String value) {
1018        try {
1019            return Integer.parseInt(value);
1020        } catch (NumberFormatException e) {
1021            return 0;
1022        }
1023    }
1024
1025    public void untie() {
1026        this.mNextMessage = null;
1027        this.mPreviousMessage = null;
1028    }
1029
1030    public boolean isPrivateMessage() {
1031        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1032    }
1033
1034    public boolean isFileOrImage() {
1035        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1036    }
1037
1038
1039    public boolean isTypeText() {
1040        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1041    }
1042
1043    public boolean hasFileOnRemoteHost() {
1044        return isFileOrImage() && getFileParams().url != null;
1045    }
1046
1047    public boolean needsUploading() {
1048        return isFileOrImage() && getFileParams().url == null;
1049    }
1050
1051    public static class FileParams {
1052        public String url;
1053        public Long size = null;
1054        public int width = 0;
1055        public int height = 0;
1056        public int runtime = 0;
1057
1058        public FileParams() { }
1059
1060        public FileParams(Element el) {
1061            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1062                this.url = el.findChildContent("url", Namespace.OOB);
1063            }
1064            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1065                final String refUri = el.getAttribute("uri");
1066                if (refUri != null) url = refUri;
1067                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1068                if (mediaSharing != null) {
1069                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1070                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1071                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1072                    if (file != null) {
1073                        String sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:5");
1074                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:4");
1075                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:3");
1076                        if (sizeS != null) size = new Long(sizeS);
1077                    }
1078
1079                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1080                    if (sources != null) {
1081                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1082                        if (ref != null) url = ref.getAttribute("uri");
1083                    }
1084                }
1085            }
1086        }
1087
1088        public FileParams(String ser) {
1089            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1090            switch (parts.length) {
1091                case 1:
1092                    try {
1093                        this.size = Long.parseLong(parts[0]);
1094                    } catch (final NumberFormatException e) {
1095                        this.url = URL.tryParse(parts[0]);
1096                    }
1097                    break;
1098                case 5:
1099                    this.runtime = parseInt(parts[4]);
1100                case 4:
1101                    this.width = parseInt(parts[2]);
1102                    this.height = parseInt(parts[3]);
1103                case 2:
1104                    this.url = URL.tryParse(parts[0]);
1105                    this.size = Longs.tryParse(parts[1]);
1106                    break;
1107                case 3:
1108                    this.size = Longs.tryParse(parts[0]);
1109                    this.width = parseInt(parts[1]);
1110                    this.height = parseInt(parts[2]);
1111                    break;
1112            }
1113        }
1114
1115        public long getSize() {
1116            return size == null ? 0 : size;
1117        }
1118
1119        public String toString() {
1120            final StringBuilder builder = new StringBuilder();
1121            if (url != null) builder.append(url);
1122            if (size != null) builder.append('|').append(size.toString());
1123            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1124            if (height > 0 || runtime > 0) builder.append('|').append(height);
1125            if (runtime > 0) builder.append('|').append(runtime);
1126            return builder.toString();
1127        }
1128    }
1129
1130    public void setFingerprint(String fingerprint) {
1131        this.axolotlFingerprint = fingerprint;
1132    }
1133
1134    public String getFingerprint() {
1135        return axolotlFingerprint;
1136    }
1137
1138    public boolean isTrusted() {
1139        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1140        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1141        return s != null && s.isTrusted();
1142    }
1143
1144    private int getPreviousEncryption() {
1145        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1146            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1147                continue;
1148            }
1149            return iterator.getEncryption();
1150        }
1151        return ENCRYPTION_NONE;
1152    }
1153
1154    private int getNextEncryption() {
1155        if (this.conversation instanceof Conversation) {
1156            Conversation conversation = (Conversation) this.conversation;
1157            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1158                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1159                    continue;
1160                }
1161                return iterator.getEncryption();
1162            }
1163            return conversation.getNextEncryption();
1164        } else {
1165            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1166        }
1167    }
1168
1169    public boolean isValidInSession() {
1170        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1171        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1172
1173        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1174                || futureEncryption == ENCRYPTION_NONE
1175                || pastEncryption != futureEncryption;
1176
1177        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1178    }
1179
1180    private static int getCleanedEncryption(int encryption) {
1181        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1182            return ENCRYPTION_PGP;
1183        }
1184        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1185            return ENCRYPTION_AXOLOTL;
1186        }
1187        return encryption;
1188    }
1189
1190    public static boolean configurePrivateMessage(final Message message) {
1191        return configurePrivateMessage(message, false);
1192    }
1193
1194    public static boolean configurePrivateFileMessage(final Message message) {
1195        return configurePrivateMessage(message, true);
1196    }
1197
1198    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1199        final Conversation conversation;
1200        if (message.conversation instanceof Conversation) {
1201            conversation = (Conversation) message.conversation;
1202        } else {
1203            return false;
1204        }
1205        if (conversation.getMode() == Conversation.MODE_MULTI) {
1206            final Jid nextCounterpart = conversation.getNextCounterpart();
1207            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1208        }
1209        return false;
1210    }
1211
1212    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1213        final Conversation conversation;
1214        if (message.conversation instanceof Conversation) {
1215            conversation = (Conversation) message.conversation;
1216        } else {
1217            return false;
1218        }
1219        return configurePrivateMessage(conversation, message, counterpart, false);
1220    }
1221
1222    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1223        if (counterpart == null) {
1224            return false;
1225        }
1226        message.setCounterpart(counterpart);
1227        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1228        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1229        return true;
1230    }
1231}