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