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 addPayload(Element el) {
 985        if (el == null) return;
 986
 987        this.payloads.add(el);
 988    }
 989
 990    public List<Element> getPayloads() {
 991       return new ArrayList<>(this.payloads);
 992    }
 993
 994    public List<Element> getFallbacks() {
 995        List<Element> fallbacks = new ArrayList<>();
 996
 997        if (this.payloads == null) return fallbacks;
 998
 999        for (Element el : this.payloads) {
1000            if (el.getName().equals("fallback") && el.getNamespace().equals("urn:xmpp:fallback:0")) {
1001                final String fallbackFor = el.getAttribute("for");
1002                if (fallbackFor == null) continue;
1003                if (fallbackFor.equals("http://jabber.org/protocol/address") || fallbackFor.equals(Namespace.OOB)) {
1004                    fallbacks.add(el);
1005                }
1006            }
1007        }
1008
1009        return fallbacks;
1010    }
1011
1012    public Element getHtml() {
1013        if (this.payloads == null) return null;
1014
1015        for (Element el : this.payloads) {
1016            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
1017                return el.getChildren().get(0);
1018            }
1019        }
1020
1021        return null;
1022   }
1023
1024    public List<Element> getCommands() {
1025        if (this.payloads == null) return null;
1026
1027        for (Element el : this.payloads) {
1028            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
1029                return el.getChildren();
1030            }
1031        }
1032
1033        return null;
1034    }
1035
1036    public String getMimeType() {
1037        String extension;
1038        if (relativeFilePath != null) {
1039            extension = MimeUtils.extractRelevantExtension(relativeFilePath);
1040        } else {
1041            final String url = URL.tryParse(getOob() == null ? body.split("\n")[0] : getOob().toString());
1042            if (url == null) {
1043                return null;
1044            }
1045            extension = MimeUtils.extractRelevantExtension(url);
1046        }
1047        return MimeUtils.guessMimeTypeFromExtension(extension);
1048    }
1049
1050    public synchronized boolean treatAsDownloadable() {
1051        if (treatAsDownloadable == null) {
1052            treatAsDownloadable = MessageUtils.treatAsDownloadable(this.body, isOOb());
1053        }
1054        return treatAsDownloadable;
1055    }
1056
1057    public synchronized boolean bodyIsOnlyEmojis() {
1058        if (isEmojisOnly == null) {
1059            isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
1060        }
1061        return isEmojisOnly;
1062    }
1063
1064    public synchronized boolean isGeoUri() {
1065        if (isGeoUri == null) {
1066            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
1067        }
1068        return isGeoUri;
1069    }
1070
1071    protected List<Element> getSims() {
1072        return payloads.stream().filter(el ->
1073            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
1074            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
1075        ).collect(Collectors.toList());
1076    }
1077
1078    public synchronized void resetFileParams() {
1079        this.fileParams = null;
1080    }
1081
1082    public synchronized void setFileParams(FileParams fileParams) {
1083        if (this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
1084            fileParams.sims = this.fileParams.sims;
1085        }
1086        this.fileParams = fileParams;
1087    }
1088
1089    public synchronized FileParams getFileParams() {
1090        if (fileParams == null) {
1091            List<Element> sims = getSims();
1092            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
1093            if (this.transferable != null) {
1094                fileParams.size = this.transferable.getFileSize();
1095            }
1096        }
1097
1098        return fileParams;
1099    }
1100
1101    private static int parseInt(String value) {
1102        try {
1103            return Integer.parseInt(value);
1104        } catch (NumberFormatException e) {
1105            return 0;
1106        }
1107    }
1108
1109    public void untie() {
1110        this.mNextMessage = null;
1111        this.mPreviousMessage = null;
1112    }
1113
1114    public boolean isPrivateMessage() {
1115        return type == TYPE_PRIVATE || type == TYPE_PRIVATE_FILE;
1116    }
1117
1118    public boolean isFileOrImage() {
1119        return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
1120    }
1121
1122
1123    public boolean isTypeText() {
1124        return type == TYPE_TEXT || type == TYPE_PRIVATE;
1125    }
1126
1127    public boolean hasFileOnRemoteHost() {
1128        return isFileOrImage() && getFileParams().url != null;
1129    }
1130
1131    public boolean needsUploading() {
1132        return isFileOrImage() && getFileParams().url == null;
1133    }
1134
1135    public static class FileParams {
1136        public String url;
1137        public Long size = null;
1138        public int width = 0;
1139        public int height = 0;
1140        public int runtime = 0;
1141        public Element sims = null;
1142
1143        public FileParams() { }
1144
1145        public FileParams(Element el) {
1146            if (el.getName().equals("x") && el.getNamespace().equals(Namespace.OOB)) {
1147                this.url = el.findChildContent("url", Namespace.OOB);
1148            }
1149            if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
1150                sims = el;
1151                final String refUri = el.getAttribute("uri");
1152                if (refUri != null) url = refUri;
1153                final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
1154                if (mediaSharing != null) {
1155                    Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1156                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1157                    if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1158                    if (file != null) {
1159                        String sizeS = file.findChildContent("size", file.getNamespace());
1160                        if (sizeS != null) size = new Long(sizeS);
1161                        String widthS = file.findChildContent("width", "https://schema.org/");
1162                        if (widthS != null) width = parseInt(widthS);
1163                        String heightS = file.findChildContent("height", "https://schema.org/");
1164                        if (heightS != null) height = parseInt(heightS);
1165                        String durationS = file.findChildContent("duration", "https://schema.org/");
1166                        if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
1167                    }
1168
1169                    final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
1170                    if (sources != null) {
1171                        final Element ref = sources.findChild("reference", "urn:xmpp:reference:0");
1172                        if (ref != null) url = ref.getAttribute("uri");
1173                    }
1174                }
1175            }
1176        }
1177
1178        public FileParams(String ser) {
1179            final String[] parts = ser == null ? new String[0] : ser.split("\\|");
1180            switch (parts.length) {
1181                case 1:
1182                    try {
1183                        this.size = Long.parseLong(parts[0]);
1184                    } catch (final NumberFormatException e) {
1185                        this.url = URL.tryParse(parts[0]);
1186                    }
1187                    break;
1188                case 5:
1189                    this.runtime = parseInt(parts[4]);
1190                case 4:
1191                    this.width = parseInt(parts[2]);
1192                    this.height = parseInt(parts[3]);
1193                case 2:
1194                    this.url = URL.tryParse(parts[0]);
1195                    this.size = Longs.tryParse(parts[1]);
1196                    break;
1197                case 3:
1198                    this.size = Longs.tryParse(parts[0]);
1199                    this.width = parseInt(parts[1]);
1200                    this.height = parseInt(parts[2]);
1201                    break;
1202            }
1203        }
1204
1205        public long getSize() {
1206            return size == null ? 0 : size;
1207        }
1208
1209        public String getName() {
1210            Element file = getFileElement();
1211            if (file == null) return null;
1212
1213            return file.findChildContent("name", file.getNamespace());
1214        }
1215
1216        public Element toSims() {
1217            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
1218            sims.setAttribute("type", "data");
1219            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1220            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
1221
1222            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1223            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1224            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1225            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1226
1227            file.removeChild(file.findChild("size", file.getNamespace()));
1228            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
1229
1230            file.removeChild(file.findChild("width", "https://schema.org/"));
1231            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
1232
1233            file.removeChild(file.findChild("height", "https://schema.org/"));
1234            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
1235
1236            file.removeChild(file.findChild("duration", "https://schema.org/"));
1237            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
1238
1239            if (url != null) {
1240                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
1241                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
1242
1243                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
1244                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
1245                source.setAttribute("type", "data");
1246                source.setAttribute("uri", url);
1247            }
1248
1249            return sims;
1250        }
1251
1252        protected Element getFileElement() {
1253            Element file = null;
1254            if (sims == null) return file;
1255
1256            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
1257            if (mediaSharing == null) return file;
1258            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
1259            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
1260            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
1261            return file;
1262        }
1263
1264        public List<Cid> getCids() {
1265            List<Cid> cids = new ArrayList<>();
1266            Element file = getFileElement();
1267            if (file == null) return cids;
1268
1269            for (Element child : file.getChildren()) {
1270                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
1271                    try {
1272                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
1273                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
1274                }
1275            }
1276
1277            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
1278
1279            return cids;
1280        }
1281
1282        public List<Element> getThumbnails() {
1283            List<Element> thumbs = new ArrayList<>();
1284            Element file = getFileElement();
1285            if (file == null) return thumbs;
1286
1287            for (Element child : file.getChildren()) {
1288                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
1289                    thumbs.add(child);
1290                }
1291            }
1292
1293            return thumbs;
1294        }
1295
1296        public String toString() {
1297            final StringBuilder builder = new StringBuilder();
1298            if (url != null) builder.append(url);
1299            if (size != null) builder.append('|').append(size.toString());
1300            if (width > 0 || height > 0 || runtime > 0) builder.append('|').append(width);
1301            if (height > 0 || runtime > 0) builder.append('|').append(height);
1302            if (runtime > 0) builder.append('|').append(runtime);
1303            return builder.toString();
1304        }
1305
1306        public boolean equals(Object o) {
1307            if (!(o instanceof FileParams)) return false;
1308            if (url == null) return false;
1309
1310            return url.equals(((FileParams) o).url);
1311        }
1312
1313        public int hashCode() {
1314            return url == null ? super.hashCode() : url.hashCode();
1315        }
1316    }
1317
1318    public void setFingerprint(String fingerprint) {
1319        this.axolotlFingerprint = fingerprint;
1320    }
1321
1322    public String getFingerprint() {
1323        return axolotlFingerprint;
1324    }
1325
1326    public boolean isTrusted() {
1327        final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
1328        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
1329        return s != null && s.isTrusted();
1330    }
1331
1332    private int getPreviousEncryption() {
1333        for (Message iterator = this.prev(); iterator != null; iterator = iterator.prev()) {
1334            if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1335                continue;
1336            }
1337            return iterator.getEncryption();
1338        }
1339        return ENCRYPTION_NONE;
1340    }
1341
1342    private int getNextEncryption() {
1343        if (this.conversation instanceof Conversation) {
1344            Conversation conversation = (Conversation) this.conversation;
1345            for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
1346                if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
1347                    continue;
1348                }
1349                return iterator.getEncryption();
1350            }
1351            return conversation.getNextEncryption();
1352        } else {
1353            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
1354        }
1355    }
1356
1357    public boolean isValidInSession() {
1358        int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
1359        int futureEncryption = getCleanedEncryption(this.getNextEncryption());
1360
1361        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
1362                || futureEncryption == ENCRYPTION_NONE
1363                || pastEncryption != futureEncryption;
1364
1365        return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
1366    }
1367
1368    private static int getCleanedEncryption(int encryption) {
1369        if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
1370            return ENCRYPTION_PGP;
1371        }
1372        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
1373            return ENCRYPTION_AXOLOTL;
1374        }
1375        return encryption;
1376    }
1377
1378    public static boolean configurePrivateMessage(final Message message) {
1379        return configurePrivateMessage(message, false);
1380    }
1381
1382    public static boolean configurePrivateFileMessage(final Message message) {
1383        return configurePrivateMessage(message, true);
1384    }
1385
1386    private static boolean configurePrivateMessage(final Message message, final boolean isFile) {
1387        final Conversation conversation;
1388        if (message.conversation instanceof Conversation) {
1389            conversation = (Conversation) message.conversation;
1390        } else {
1391            return false;
1392        }
1393        if (conversation.getMode() == Conversation.MODE_MULTI) {
1394            final Jid nextCounterpart = conversation.getNextCounterpart();
1395            return configurePrivateMessage(conversation, message, nextCounterpart, isFile);
1396        }
1397        return false;
1398    }
1399
1400    public static boolean configurePrivateMessage(final Message message, final Jid counterpart) {
1401        final Conversation conversation;
1402        if (message.conversation instanceof Conversation) {
1403            conversation = (Conversation) message.conversation;
1404        } else {
1405            return false;
1406        }
1407        return configurePrivateMessage(conversation, message, counterpart, false);
1408    }
1409
1410    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
1411        if (counterpart == null) {
1412            return false;
1413        }
1414        message.setCounterpart(counterpart);
1415        message.setTrueCounterpart(conversation.getMucOptions().getTrueCounterpart(counterpart));
1416        message.setType(isFile ? Message.TYPE_PRIVATE_FILE : Message.TYPE_PRIVATE);
1417        return true;
1418    }
1419}