Conversation.java

   1package eu.siacs.conversations.entities;
   2
   3import android.content.ContentValues;
   4import android.database.Cursor;
   5import android.text.TextUtils;
   6import android.view.LayoutInflater;
   7import android.view.View;
   8import android.view.ViewGroup;
   9
  10import androidx.annotation.NonNull;
  11import androidx.annotation.Nullable;
  12import androidx.databinding.DataBindingUtil;
  13import androidx.databinding.ViewDataBinding;
  14import androidx.viewpager.widget.PagerAdapter;
  15import androidx.recyclerview.widget.RecyclerView;
  16import androidx.viewpager.widget.ViewPager;
  17
  18import com.google.android.material.tabs.TabLayout;
  19import com.google.common.collect.ComparisonChain;
  20import com.google.common.collect.Lists;
  21
  22import org.json.JSONArray;
  23import org.json.JSONException;
  24import org.json.JSONObject;
  25
  26import java.util.ArrayList;
  27import java.util.Collections;
  28import java.util.Iterator;
  29import java.util.List;
  30import java.util.ListIterator;
  31import java.util.concurrent.atomic.AtomicBoolean;
  32
  33import eu.siacs.conversations.Config;
  34import eu.siacs.conversations.R;
  35import eu.siacs.conversations.crypto.OmemoSetting;
  36import eu.siacs.conversations.crypto.PgpDecryptionService;
  37import eu.siacs.conversations.databinding.CommandPageBinding;
  38import eu.siacs.conversations.databinding.CommandNoteBinding;
  39import eu.siacs.conversations.persistance.DatabaseBackend;
  40import eu.siacs.conversations.services.AvatarService;
  41import eu.siacs.conversations.services.QuickConversationsService;
  42import eu.siacs.conversations.services.XmppConnectionService;
  43import eu.siacs.conversations.utils.JidHelper;
  44import eu.siacs.conversations.utils.MessageUtils;
  45import eu.siacs.conversations.utils.UIHelper;
  46import eu.siacs.conversations.xml.Element;
  47import eu.siacs.conversations.xml.Namespace;
  48import eu.siacs.conversations.xmpp.Jid;
  49import eu.siacs.conversations.xmpp.chatstate.ChatState;
  50import eu.siacs.conversations.xmpp.mam.MamReference;
  51import eu.siacs.conversations.xmpp.stanzas.IqPacket;
  52
  53import static eu.siacs.conversations.entities.Bookmark.printableValue;
  54
  55
  56public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
  57    public static final String TABLENAME = "conversations";
  58
  59    public static final int STATUS_AVAILABLE = 0;
  60    public static final int STATUS_ARCHIVED = 1;
  61
  62    public static final String NAME = "name";
  63    public static final String ACCOUNT = "accountUuid";
  64    public static final String CONTACT = "contactUuid";
  65    public static final String CONTACTJID = "contactJid";
  66    public static final String STATUS = "status";
  67    public static final String CREATED = "created";
  68    public static final String MODE = "mode";
  69    public static final String ATTRIBUTES = "attributes";
  70
  71    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
  72    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
  73    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
  74    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
  75    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
  76    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
  77    static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
  78    static final String ATTRIBUTE_MODERATED = "moderated";
  79    static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
  80    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
  81    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
  82    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
  83    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
  84    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
  85    protected final ArrayList<Message> messages = new ArrayList<>();
  86    public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
  87    protected Account account = null;
  88    private String draftMessage;
  89    private final String name;
  90    private final String contactUuid;
  91    private final String accountUuid;
  92    private Jid contactJid;
  93    private int status;
  94    private final long created;
  95    private int mode;
  96    private JSONObject attributes;
  97    private Jid nextCounterpart;
  98    private transient MucOptions mucOptions = null;
  99    private boolean messagesLeftOnServer = true;
 100    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
 101    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
 102    private String mFirstMamReference = null;
 103    protected int mCurrentTab = -1;
 104    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
 105
 106    public Conversation(final String name, final Account account, final Jid contactJid,
 107                        final int mode) {
 108        this(java.util.UUID.randomUUID().toString(), name, null, account
 109                        .getUuid(), contactJid, System.currentTimeMillis(),
 110                STATUS_AVAILABLE, mode, "");
 111        this.account = account;
 112    }
 113
 114    public Conversation(final String uuid, final String name, final String contactUuid,
 115                        final String accountUuid, final Jid contactJid, final long created, final int status,
 116                        final int mode, final String attributes) {
 117        this.uuid = uuid;
 118        this.name = name;
 119        this.contactUuid = contactUuid;
 120        this.accountUuid = accountUuid;
 121        this.contactJid = contactJid;
 122        this.created = created;
 123        this.status = status;
 124        this.mode = mode;
 125        try {
 126            this.attributes = new JSONObject(attributes == null ? "" : attributes);
 127        } catch (JSONException e) {
 128            this.attributes = new JSONObject();
 129        }
 130    }
 131
 132    public static Conversation fromCursor(Cursor cursor) {
 133        return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
 134                cursor.getString(cursor.getColumnIndex(NAME)),
 135                cursor.getString(cursor.getColumnIndex(CONTACT)),
 136                cursor.getString(cursor.getColumnIndex(ACCOUNT)),
 137                JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
 138                cursor.getLong(cursor.getColumnIndex(CREATED)),
 139                cursor.getInt(cursor.getColumnIndex(STATUS)),
 140                cursor.getInt(cursor.getColumnIndex(MODE)),
 141                cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
 142    }
 143
 144    public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
 145        for (int i = messages.size() - 1; i >= 0; --i) {
 146            final Message message = messages.get(i);
 147            if (message.getStatus() <= Message.STATUS_RECEIVED
 148                    && (message.markable || isPrivateAndNonAnonymousMuc)
 149                    && !message.isPrivateMessage()) {
 150                return message;
 151            }
 152        }
 153        return null;
 154    }
 155
 156    private static boolean suitableForOmemoByDefault(final Conversation conversation) {
 157        if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
 158            return false;
 159        }
 160        if (conversation.getContact().isOwnServer()) {
 161            return false;
 162        }
 163        final String contact = conversation.getJid().getDomain().toEscapedString();
 164        final String account = conversation.getAccount().getServer();
 165        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
 166            return false;
 167        }
 168        return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
 169    }
 170
 171    public boolean hasMessagesLeftOnServer() {
 172        return messagesLeftOnServer;
 173    }
 174
 175    public void setHasMessagesLeftOnServer(boolean value) {
 176        this.messagesLeftOnServer = value;
 177    }
 178
 179    public Message getFirstUnreadMessage() {
 180        Message first = null;
 181        synchronized (this.messages) {
 182            for (int i = messages.size() - 1; i >= 0; --i) {
 183                if (messages.get(i).isRead()) {
 184                    return first;
 185                } else {
 186                    first = messages.get(i);
 187                }
 188            }
 189        }
 190        return first;
 191    }
 192
 193    public String findMostRecentRemoteDisplayableId() {
 194        final boolean multi = mode == Conversation.MODE_MULTI;
 195        synchronized (this.messages) {
 196            for (final Message message : Lists.reverse(this.messages)) {
 197                if (message.getStatus() == Message.STATUS_RECEIVED) {
 198                    final String serverMsgId = message.getServerMsgId();
 199                    if (serverMsgId != null && multi) {
 200                        return serverMsgId;
 201                    }
 202                    return message.getRemoteMsgId();
 203                }
 204            }
 205        }
 206        return null;
 207    }
 208
 209    public int countFailedDeliveries() {
 210        int count = 0;
 211        synchronized (this.messages) {
 212            for(final Message message : this.messages) {
 213                if (message.getStatus() == Message.STATUS_SEND_FAILED) {
 214                    ++count;
 215                }
 216            }
 217        }
 218        return count;
 219    }
 220
 221    public Message getLastEditableMessage() {
 222        synchronized (this.messages) {
 223            for (final Message message : Lists.reverse(this.messages)) {
 224                if (message.isEditable()) {
 225                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
 226                        return null;
 227                    }
 228                    return message;
 229                }
 230            }
 231        }
 232        return null;
 233    }
 234
 235
 236    public Message findUnsentMessageWithUuid(String uuid) {
 237        synchronized (this.messages) {
 238            for (final Message message : this.messages) {
 239                final int s = message.getStatus();
 240                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
 241                    return message;
 242                }
 243            }
 244        }
 245        return null;
 246    }
 247
 248    public void findWaitingMessages(OnMessageFound onMessageFound) {
 249        final ArrayList<Message> results = new ArrayList<>();
 250        synchronized (this.messages) {
 251            for (Message message : this.messages) {
 252                if (message.getStatus() == Message.STATUS_WAITING) {
 253                    results.add(message);
 254                }
 255            }
 256        }
 257        for (Message result : results) {
 258            onMessageFound.onMessageFound(result);
 259        }
 260    }
 261
 262    public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
 263        final ArrayList<Message> results = new ArrayList<>();
 264        synchronized (this.messages) {
 265            for (final Message message : this.messages) {
 266                if (message.isRead()) {
 267                    continue;
 268                }
 269                results.add(message);
 270            }
 271        }
 272        for (final Message result : results) {
 273            onMessageFound.onMessageFound(result);
 274        }
 275    }
 276
 277    public Message findMessageWithFileAndUuid(final String uuid) {
 278        synchronized (this.messages) {
 279            for (final Message message : this.messages) {
 280                final Transferable transferable = message.getTransferable();
 281                final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 282                if (message.getUuid().equals(uuid)
 283                        && message.getEncryption() != Message.ENCRYPTION_PGP
 284                        && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
 285                    return message;
 286                }
 287            }
 288        }
 289        return null;
 290    }
 291
 292    public Message findMessageWithUuid(final String uuid) {
 293        synchronized (this.messages) {
 294            for (final Message message : this.messages) {
 295                if (message.getUuid().equals(uuid)) {
 296                    return message;
 297                }
 298            }
 299        }
 300        return null;
 301    }
 302
 303    public boolean markAsDeleted(final List<String> uuids) {
 304        boolean deleted = false;
 305        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 306        synchronized (this.messages) {
 307            for (Message message : this.messages) {
 308                if (uuids.contains(message.getUuid())) {
 309                    message.setDeleted(true);
 310                    deleted = true;
 311                    if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 312                        pgpDecryptionService.discard(message);
 313                    }
 314                }
 315            }
 316        }
 317        return deleted;
 318    }
 319
 320    public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
 321        boolean changed = false;
 322        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 323        synchronized (this.messages) {
 324            for (Message message : this.messages) {
 325                for (final DatabaseBackend.FilePathInfo file : files)
 326                    if (file.uuid.toString().equals(message.getUuid())) {
 327                        message.setDeleted(file.deleted);
 328                        changed = true;
 329                        if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 330                            pgpDecryptionService.discard(message);
 331                        }
 332                    }
 333            }
 334        }
 335        return changed;
 336    }
 337
 338    public void clearMessages() {
 339        synchronized (this.messages) {
 340            this.messages.clear();
 341        }
 342    }
 343
 344    public boolean setIncomingChatState(ChatState state) {
 345        if (this.mIncomingChatState == state) {
 346            return false;
 347        }
 348        this.mIncomingChatState = state;
 349        return true;
 350    }
 351
 352    public ChatState getIncomingChatState() {
 353        return this.mIncomingChatState;
 354    }
 355
 356    public boolean setOutgoingChatState(ChatState state) {
 357        if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
 358            if (this.mOutgoingChatState != state) {
 359                this.mOutgoingChatState = state;
 360                return true;
 361            }
 362        }
 363        return false;
 364    }
 365
 366    public ChatState getOutgoingChatState() {
 367        return this.mOutgoingChatState;
 368    }
 369
 370    public void trim() {
 371        synchronized (this.messages) {
 372            final int size = messages.size();
 373            final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
 374            if (size > maxsize) {
 375                List<Message> discards = this.messages.subList(0, size - maxsize);
 376                final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 377                if (pgpDecryptionService != null) {
 378                    pgpDecryptionService.discard(discards);
 379                }
 380                discards.clear();
 381                untieMessages();
 382            }
 383        }
 384    }
 385
 386    public void findUnsentTextMessages(OnMessageFound onMessageFound) {
 387        final ArrayList<Message> results = new ArrayList<>();
 388        synchronized (this.messages) {
 389            for (Message message : this.messages) {
 390                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
 391                    results.add(message);
 392                }
 393            }
 394        }
 395        for (Message result : results) {
 396            onMessageFound.onMessageFound(result);
 397        }
 398    }
 399
 400    public Message findSentMessageWithUuidOrRemoteId(String id) {
 401        synchronized (this.messages) {
 402            for (Message message : this.messages) {
 403                if (id.equals(message.getUuid())
 404                        || (message.getStatus() >= Message.STATUS_SEND
 405                        && id.equals(message.getRemoteMsgId()))) {
 406                    return message;
 407                }
 408            }
 409        }
 410        return null;
 411    }
 412
 413    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
 414        synchronized (this.messages) {
 415            for (int i = this.messages.size() - 1; i >= 0; --i) {
 416                final Message message = messages.get(i);
 417                final Jid mcp = message.getCounterpart();
 418                if (mcp == null) {
 419                    continue;
 420                }
 421                if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
 422                        && (carbon == message.isCarbon() || received)) {
 423                    final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
 424                    if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
 425                        return message;
 426                    } else {
 427                        return null;
 428                    }
 429                }
 430            }
 431        }
 432        return null;
 433    }
 434
 435    public Message findSentMessageWithUuid(String id) {
 436        synchronized (this.messages) {
 437            for (Message message : this.messages) {
 438                if (id.equals(message.getUuid())) {
 439                    return message;
 440                }
 441            }
 442        }
 443        return null;
 444    }
 445
 446    public Message findMessageWithRemoteId(String id, Jid counterpart) {
 447        synchronized (this.messages) {
 448            for (Message message : this.messages) {
 449                if (counterpart.equals(message.getCounterpart())
 450                        && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
 451                    return message;
 452                }
 453            }
 454        }
 455        return null;
 456    }
 457
 458    public Message findMessageWithServerMsgId(String id) {
 459        synchronized (this.messages) {
 460            for (Message message : this.messages) {
 461                if (id != null && id.equals(message.getServerMsgId())) {
 462                    return message;
 463                }
 464            }
 465        }
 466        return null;
 467    }
 468
 469    public boolean hasMessageWithCounterpart(Jid counterpart) {
 470        synchronized (this.messages) {
 471            for (Message message : this.messages) {
 472                if (counterpart.equals(message.getCounterpart())) {
 473                    return true;
 474                }
 475            }
 476        }
 477        return false;
 478    }
 479
 480    public void populateWithMessages(final List<Message> messages) {
 481        synchronized (this.messages) {
 482            messages.clear();
 483            messages.addAll(this.messages);
 484        }
 485        for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
 486            if (iterator.next().wasMergedIntoPrevious()) {
 487                iterator.remove();
 488            }
 489        }
 490    }
 491
 492    @Override
 493    public boolean isBlocked() {
 494        return getContact().isBlocked();
 495    }
 496
 497    @Override
 498    public boolean isDomainBlocked() {
 499        return getContact().isDomainBlocked();
 500    }
 501
 502    @Override
 503    public Jid getBlockedJid() {
 504        return getContact().getBlockedJid();
 505    }
 506
 507    public int countMessages() {
 508        synchronized (this.messages) {
 509            return this.messages.size();
 510        }
 511    }
 512
 513    public String getFirstMamReference() {
 514        return this.mFirstMamReference;
 515    }
 516
 517    public void setFirstMamReference(String reference) {
 518        this.mFirstMamReference = reference;
 519    }
 520
 521    public void setLastClearHistory(long time, String reference) {
 522        if (reference != null) {
 523            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
 524        } else {
 525            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
 526        }
 527    }
 528
 529    public MamReference getLastClearHistory() {
 530        return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
 531    }
 532
 533    public List<Jid> getAcceptedCryptoTargets() {
 534        if (mode == MODE_SINGLE) {
 535            return Collections.singletonList(getJid().asBareJid());
 536        } else {
 537            return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
 538        }
 539    }
 540
 541    public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
 542        setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
 543    }
 544
 545    public boolean setCorrectingMessage(Message correctingMessage) {
 546        setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
 547        return correctingMessage == null && draftMessage != null;
 548    }
 549
 550    public Message getCorrectingMessage() {
 551        final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
 552        return uuid == null ? null : findSentMessageWithUuid(uuid);
 553    }
 554
 555    public boolean withSelf() {
 556        return getContact().isSelf();
 557    }
 558
 559    @Override
 560    public int compareTo(@NonNull Conversation another) {
 561        return ComparisonChain.start()
 562                .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
 563                .compare(another.getSortableTime(), getSortableTime())
 564                .result();
 565    }
 566
 567    private long getSortableTime() {
 568        Draft draft = getDraft();
 569        long messageTime = getLatestMessage().getTimeSent();
 570        if (draft == null) {
 571            return messageTime;
 572        } else {
 573            return Math.max(messageTime, draft.getTimestamp());
 574        }
 575    }
 576
 577    public String getDraftMessage() {
 578        return draftMessage;
 579    }
 580
 581    public void setDraftMessage(String draftMessage) {
 582        this.draftMessage = draftMessage;
 583    }
 584
 585    public boolean isRead() {
 586        synchronized (this.messages) {
 587            for(final Message message : Lists.reverse(this.messages)) {
 588                if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
 589                    continue;
 590                }
 591                return message.isRead();
 592            }
 593            return true;
 594        }
 595    }
 596
 597    public List<Message> markRead(String upToUuid) {
 598        final List<Message> unread = new ArrayList<>();
 599        synchronized (this.messages) {
 600            for (Message message : this.messages) {
 601                if (!message.isRead()) {
 602                    message.markRead();
 603                    unread.add(message);
 604                }
 605                if (message.getUuid().equals(upToUuid)) {
 606                    return unread;
 607                }
 608            }
 609        }
 610        return unread;
 611    }
 612
 613    public Message getLatestMessage() {
 614        synchronized (this.messages) {
 615            if (this.messages.size() == 0) {
 616                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
 617                message.setType(Message.TYPE_STATUS);
 618                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 619                return message;
 620            } else {
 621                return this.messages.get(this.messages.size() - 1);
 622            }
 623        }
 624    }
 625
 626    public @NonNull
 627    CharSequence getName() {
 628        if (getMode() == MODE_MULTI) {
 629            final String roomName = getMucOptions().getName();
 630            final String subject = getMucOptions().getSubject();
 631            final Bookmark bookmark = getBookmark();
 632            final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
 633            if (printableValue(roomName)) {
 634                return roomName;
 635            } else if (printableValue(subject)) {
 636                return subject;
 637            } else if (printableValue(bookmarkName, false)) {
 638                return bookmarkName;
 639            } else {
 640                final String generatedName = getMucOptions().createNameFromParticipants();
 641                if (printableValue(generatedName)) {
 642                    return generatedName;
 643                } else {
 644                    return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
 645                }
 646            }
 647        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
 648            return contactJid;
 649        } else {
 650            return this.getContact().getDisplayName();
 651        }
 652    }
 653
 654    public String getAccountUuid() {
 655        return this.accountUuid;
 656    }
 657
 658    public Account getAccount() {
 659        return this.account;
 660    }
 661
 662    public void setAccount(final Account account) {
 663        this.account = account;
 664    }
 665
 666    public Contact getContact() {
 667        return this.account.getRoster().getContact(this.contactJid);
 668    }
 669
 670    @Override
 671    public Jid getJid() {
 672        return this.contactJid;
 673    }
 674
 675    public int getStatus() {
 676        return this.status;
 677    }
 678
 679    public void setStatus(int status) {
 680        this.status = status;
 681    }
 682
 683    public long getCreated() {
 684        return this.created;
 685    }
 686
 687    public ContentValues getContentValues() {
 688        ContentValues values = new ContentValues();
 689        values.put(UUID, uuid);
 690        values.put(NAME, name);
 691        values.put(CONTACT, contactUuid);
 692        values.put(ACCOUNT, accountUuid);
 693        values.put(CONTACTJID, contactJid.toString());
 694        values.put(CREATED, created);
 695        values.put(STATUS, status);
 696        values.put(MODE, mode);
 697        synchronized (this.attributes) {
 698            values.put(ATTRIBUTES, attributes.toString());
 699        }
 700        return values;
 701    }
 702
 703    public int getMode() {
 704        return this.mode;
 705    }
 706
 707    public void setMode(int mode) {
 708        this.mode = mode;
 709    }
 710
 711    /**
 712     * short for is Private and Non-anonymous
 713     */
 714    public boolean isSingleOrPrivateAndNonAnonymous() {
 715        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 716    }
 717
 718    public boolean isPrivateAndNonAnonymous() {
 719        return getMucOptions().isPrivateAndNonAnonymous();
 720    }
 721
 722    public synchronized MucOptions getMucOptions() {
 723        if (this.mucOptions == null) {
 724            this.mucOptions = new MucOptions(this);
 725        }
 726        return this.mucOptions;
 727    }
 728
 729    public void resetMucOptions() {
 730        this.mucOptions = null;
 731    }
 732
 733    public void setContactJid(final Jid jid) {
 734        this.contactJid = jid;
 735    }
 736
 737    public Jid getNextCounterpart() {
 738        return this.nextCounterpart;
 739    }
 740
 741    public void setNextCounterpart(Jid jid) {
 742        this.nextCounterpart = jid;
 743    }
 744
 745    public int getNextEncryption() {
 746        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 747            return Message.ENCRYPTION_NONE;
 748        }
 749        if (OmemoSetting.isAlways()) {
 750            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 751        }
 752        final int defaultEncryption;
 753        if (suitableForOmemoByDefault(this)) {
 754            defaultEncryption = OmemoSetting.getEncryption();
 755        } else {
 756            defaultEncryption = Message.ENCRYPTION_NONE;
 757        }
 758        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 759        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 760            return defaultEncryption;
 761        } else {
 762            return encryption;
 763        }
 764    }
 765
 766    public boolean setNextEncryption(int encryption) {
 767        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 768    }
 769
 770    public String getNextMessage() {
 771        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 772        return nextMessage == null ? "" : nextMessage;
 773    }
 774
 775    public @Nullable
 776    Draft getDraft() {
 777        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 778        if (timestamp > getLatestMessage().getTimeSent()) {
 779            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 780            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 781                return new Draft(message, timestamp);
 782            }
 783        }
 784        return null;
 785    }
 786
 787    public boolean setNextMessage(final String input) {
 788        final String message = input == null || input.trim().isEmpty() ? null : input;
 789        boolean changed = !getNextMessage().equals(message);
 790        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 791        if (changed) {
 792            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
 793        }
 794        return changed;
 795    }
 796
 797    public Bookmark getBookmark() {
 798        return this.account.getBookmark(this.contactJid);
 799    }
 800
 801    public Message findDuplicateMessage(Message message) {
 802        synchronized (this.messages) {
 803            for (int i = this.messages.size() - 1; i >= 0; --i) {
 804                if (this.messages.get(i).similar(message)) {
 805                    return this.messages.get(i);
 806                }
 807            }
 808        }
 809        return null;
 810    }
 811
 812    public boolean hasDuplicateMessage(Message message) {
 813        return findDuplicateMessage(message) != null;
 814    }
 815
 816    public Message findSentMessageWithBody(String body) {
 817        synchronized (this.messages) {
 818            for (int i = this.messages.size() - 1; i >= 0; --i) {
 819                Message message = this.messages.get(i);
 820                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
 821                    String otherBody;
 822                    if (message.hasFileOnRemoteHost()) {
 823                        otherBody = message.getFileParams().url;
 824                    } else {
 825                        otherBody = message.body;
 826                    }
 827                    if (otherBody != null && otherBody.equals(body)) {
 828                        return message;
 829                    }
 830                }
 831            }
 832            return null;
 833        }
 834    }
 835
 836    public Message findRtpSession(final String sessionId, final int s) {
 837        synchronized (this.messages) {
 838            for (int i = this.messages.size() - 1; i >= 0; --i) {
 839                final Message message = this.messages.get(i);
 840                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
 841                    return message;
 842                }
 843            }
 844        }
 845        return null;
 846    }
 847
 848    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
 849        if (serverMsgId == null || remoteMsgId == null) {
 850            return false;
 851        }
 852        synchronized (this.messages) {
 853            for (Message message : this.messages) {
 854                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
 855                    return true;
 856                }
 857            }
 858        }
 859        return false;
 860    }
 861
 862    public MamReference getLastMessageTransmitted() {
 863        final MamReference lastClear = getLastClearHistory();
 864        MamReference lastReceived = new MamReference(0);
 865        synchronized (this.messages) {
 866            for (int i = this.messages.size() - 1; i >= 0; --i) {
 867                final Message message = this.messages.get(i);
 868                if (message.isPrivateMessage()) {
 869                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
 870                }
 871                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
 872                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
 873                    break;
 874                }
 875            }
 876        }
 877        return MamReference.max(lastClear, lastReceived);
 878    }
 879
 880    public void setMutedTill(long value) {
 881        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
 882    }
 883
 884    public boolean isMuted() {
 885        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
 886    }
 887
 888    public boolean alwaysNotify() {
 889        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
 890    }
 891
 892    public boolean setAttribute(String key, boolean value) {
 893        return setAttribute(key, String.valueOf(value));
 894    }
 895
 896    private boolean setAttribute(String key, long value) {
 897        return setAttribute(key, Long.toString(value));
 898    }
 899
 900    private boolean setAttribute(String key, int value) {
 901        return setAttribute(key, String.valueOf(value));
 902    }
 903
 904    public boolean setAttribute(String key, String value) {
 905        synchronized (this.attributes) {
 906            try {
 907                if (value == null) {
 908                    if (this.attributes.has(key)) {
 909                        this.attributes.remove(key);
 910                        return true;
 911                    } else {
 912                        return false;
 913                    }
 914                } else {
 915                    final String prev = this.attributes.optString(key, null);
 916                    this.attributes.put(key, value);
 917                    return !value.equals(prev);
 918                }
 919            } catch (JSONException e) {
 920                throw new AssertionError(e);
 921            }
 922        }
 923    }
 924
 925    public boolean setAttribute(String key, List<Jid> jids) {
 926        JSONArray array = new JSONArray();
 927        for (Jid jid : jids) {
 928            array.put(jid.asBareJid().toString());
 929        }
 930        synchronized (this.attributes) {
 931            try {
 932                this.attributes.put(key, array);
 933                return true;
 934            } catch (JSONException e) {
 935                return false;
 936            }
 937        }
 938    }
 939
 940    public String getAttribute(String key) {
 941        synchronized (this.attributes) {
 942            return this.attributes.optString(key, null);
 943        }
 944    }
 945
 946    private List<Jid> getJidListAttribute(String key) {
 947        ArrayList<Jid> list = new ArrayList<>();
 948        synchronized (this.attributes) {
 949            try {
 950                JSONArray array = this.attributes.getJSONArray(key);
 951                for (int i = 0; i < array.length(); ++i) {
 952                    try {
 953                        list.add(Jid.of(array.getString(i)));
 954                    } catch (IllegalArgumentException e) {
 955                        //ignored
 956                    }
 957                }
 958            } catch (JSONException e) {
 959                //ignored
 960            }
 961        }
 962        return list;
 963    }
 964
 965    private int getIntAttribute(String key, int defaultValue) {
 966        String value = this.getAttribute(key);
 967        if (value == null) {
 968            return defaultValue;
 969        } else {
 970            try {
 971                return Integer.parseInt(value);
 972            } catch (NumberFormatException e) {
 973                return defaultValue;
 974            }
 975        }
 976    }
 977
 978    public long getLongAttribute(String key, long defaultValue) {
 979        String value = this.getAttribute(key);
 980        if (value == null) {
 981            return defaultValue;
 982        } else {
 983            try {
 984                return Long.parseLong(value);
 985            } catch (NumberFormatException e) {
 986                return defaultValue;
 987            }
 988        }
 989    }
 990
 991    public boolean getBooleanAttribute(String key, boolean defaultValue) {
 992        String value = this.getAttribute(key);
 993        if (value == null) {
 994            return defaultValue;
 995        } else {
 996            return Boolean.parseBoolean(value);
 997        }
 998    }
 999
1000    public void add(Message message) {
1001        synchronized (this.messages) {
1002            this.messages.add(message);
1003        }
1004    }
1005
1006    public void prepend(int offset, Message message) {
1007        synchronized (this.messages) {
1008            this.messages.add(Math.min(offset, this.messages.size()), message);
1009        }
1010    }
1011
1012    public void addAll(int index, List<Message> messages) {
1013        synchronized (this.messages) {
1014            this.messages.addAll(index, messages);
1015        }
1016        account.getPgpDecryptionService().decrypt(messages);
1017    }
1018
1019    public void expireOldMessages(long timestamp) {
1020        synchronized (this.messages) {
1021            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1022                if (iterator.next().getTimeSent() < timestamp) {
1023                    iterator.remove();
1024                }
1025            }
1026            untieMessages();
1027        }
1028    }
1029
1030    public void sort() {
1031        synchronized (this.messages) {
1032            Collections.sort(this.messages, (left, right) -> {
1033                if (left.getTimeSent() < right.getTimeSent()) {
1034                    return -1;
1035                } else if (left.getTimeSent() > right.getTimeSent()) {
1036                    return 1;
1037                } else {
1038                    return 0;
1039                }
1040            });
1041            untieMessages();
1042        }
1043    }
1044
1045    private void untieMessages() {
1046        for (Message message : this.messages) {
1047            message.untie();
1048        }
1049    }
1050
1051    public int unreadCount() {
1052        synchronized (this.messages) {
1053            int count = 0;
1054            for(final Message message : Lists.reverse(this.messages)) {
1055                if (message.isRead()) {
1056                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1057                        continue;
1058                    }
1059                    return count;
1060                }
1061                ++count;
1062            }
1063            return count;
1064        }
1065    }
1066
1067    public int receivedMessagesCount() {
1068        int count = 0;
1069        synchronized (this.messages) {
1070            for (Message message : messages) {
1071                if (message.getStatus() == Message.STATUS_RECEIVED) {
1072                    ++count;
1073                }
1074            }
1075        }
1076        return count;
1077    }
1078
1079    public int sentMessagesCount() {
1080        int count = 0;
1081        synchronized (this.messages) {
1082            for (Message message : messages) {
1083                if (message.getStatus() != Message.STATUS_RECEIVED) {
1084                    ++count;
1085                }
1086            }
1087        }
1088        return count;
1089    }
1090
1091    public boolean isWithStranger() {
1092        final Contact contact = getContact();
1093        return mode == MODE_SINGLE
1094                && !contact.isOwnServer()
1095                && !contact.showInContactList()
1096                && !contact.isSelf()
1097                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1098                && sentMessagesCount() == 0;
1099    }
1100
1101    public int getReceivedMessagesCountSinceUuid(String uuid) {
1102        if (uuid == null) {
1103            return 0;
1104        }
1105        int count = 0;
1106        synchronized (this.messages) {
1107            for (int i = messages.size() - 1; i >= 0; i--) {
1108                final Message message = messages.get(i);
1109                if (uuid.equals(message.getUuid())) {
1110                    return count;
1111                }
1112                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1113                    ++count;
1114                }
1115            }
1116        }
1117        return 0;
1118    }
1119
1120    @Override
1121    public int getAvatarBackgroundColor() {
1122        return UIHelper.getColorForName(getName().toString());
1123    }
1124
1125    @Override
1126    public String getAvatarName() {
1127        return getName().toString();
1128    }
1129
1130    public void setCurrentTab(int tab) {
1131        mCurrentTab = tab;
1132    }
1133
1134    public int getCurrentTab() {
1135        if (mCurrentTab >= 0) return mCurrentTab;
1136
1137        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1138            return 0;
1139        }
1140
1141        return 1;
1142    }
1143
1144    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1145        pagerAdapter.startCommand(command, xmppConnectionService);
1146    }
1147
1148    public void setupViewPager(ViewPager pager, TabLayout tabs) {
1149        pagerAdapter.setupViewPager(pager, tabs);
1150    }
1151
1152    public interface OnMessageFound {
1153        void onMessageFound(final Message message);
1154    }
1155
1156    public static class Draft {
1157        private final String message;
1158        private final long timestamp;
1159
1160        private Draft(String message, long timestamp) {
1161            this.message = message;
1162            this.timestamp = timestamp;
1163        }
1164
1165        public long getTimestamp() {
1166            return timestamp;
1167        }
1168
1169        public String getMessage() {
1170            return message;
1171        }
1172    }
1173
1174    public class ConversationPagerAdapter extends PagerAdapter {
1175        protected ViewPager mPager = null;
1176        protected TabLayout mTabs = null;
1177        ArrayList<CommandSession> sessions = new ArrayList<>();
1178
1179        public void setupViewPager(ViewPager pager, TabLayout tabs) {
1180            mPager = pager;
1181            mTabs = tabs;
1182            pager.setAdapter(this);
1183            tabs.setupWithViewPager(mPager);
1184            pager.setCurrentItem(getCurrentTab());
1185
1186            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1187                public void onPageScrollStateChanged(int state) { }
1188                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1189
1190                public void onPageSelected(int position) {
1191                    setCurrentTab(position);
1192                }
1193            });
1194        }
1195
1196        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1197            CommandSession session = new CommandSession(command.getAttribute("name"));
1198
1199            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1200            packet.setTo(command.getAttributeAsJid("jid"));
1201            final Element c = packet.addChild("command", Namespace.COMMANDS);
1202            c.setAttribute("node", command.getAttribute("node"));
1203            c.setAttribute("action", "execute");
1204            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1205                mPager.post(() -> {
1206                    session.updateWithResponse(iq);
1207                });
1208            });
1209
1210            sessions.add(session);
1211            notifyDataSetChanged();
1212            mPager.setCurrentItem(getCount() - 1);
1213        }
1214
1215        @NonNull
1216        @Override
1217        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1218            if (position < 2) {
1219              return mPager.getChildAt(position);
1220            }
1221
1222            CommandSession session = sessions.get(position-2);
1223            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1224            container.addView(binding.getRoot());
1225            binding.form.setAdapter(session);
1226            binding.done.setOnClickListener((button) -> {
1227                sessions.remove(session);
1228                notifyDataSetChanged();
1229            });
1230
1231            session.setBinding(binding);
1232            return session;
1233        }
1234
1235        @Override
1236        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1237            if (position < 2) return;
1238
1239            container.removeView(((CommandSession) o).getView());
1240        }
1241
1242        @Override
1243        public int getItemPosition(Object o) {
1244            if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1245            if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1246
1247            int pos = sessions.indexOf(o);
1248            if (pos < 0) return PagerAdapter.POSITION_NONE;
1249            return pos + 2;
1250        }
1251
1252        @Override
1253        public int getCount() {
1254            int count = 2 + sessions.size();
1255            if (count > 2) {
1256                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1257            } else {
1258                mTabs.setTabMode(TabLayout.MODE_FIXED);
1259            }
1260            return count;
1261        }
1262
1263        @Override
1264        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1265            if (view == o) return true;
1266
1267            if (o instanceof CommandSession) {
1268                return ((CommandSession) o).getView() == view;
1269            }
1270
1271            return false;
1272        }
1273
1274        @Nullable
1275        @Override
1276        public CharSequence getPageTitle(int position) {
1277            switch (position) {
1278                case 0:
1279                    return "Conversation";
1280                case 1:
1281                    return "Commands";
1282                default:
1283                    CommandSession session = sessions.get(position-2);
1284                    if (session == null) return super.getPageTitle(position);
1285                    return session.getTitle();
1286            }
1287        }
1288
1289        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1290            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1291                protected T binding;
1292
1293                public ViewHolder(T binding) {
1294                    super(binding.getRoot());
1295                    this.binding = binding;
1296                }
1297
1298                abstract public void bind(IqPacket iq, int position);
1299            }
1300
1301            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1302                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1303
1304                @Override
1305                public void bind(IqPacket iq, int position) {
1306                    binding.errorIcon.setVisibility(View.VISIBLE);
1307
1308                    Element error = iq.findChild("error");
1309                    if (error == null) return;
1310                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1311                    if (text == null || text.equals("")) {
1312                        text = error.getChildren().get(0).getName();
1313                    }
1314                    binding.message.setText(text);
1315                }
1316            }
1317
1318            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1319                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1320
1321                @Override
1322                public void bind(IqPacket iq, int position) {
1323                    Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
1324                    if (command == null) return;
1325                    Element note = command.findChild("note", "http://jabber.org/protocol/commands");
1326                    if (note == null) return;
1327                    binding.message.setText(note.getContent());
1328
1329                    if (note.getAttribute("type").equals("error")) {
1330                        binding.errorIcon.setVisibility(View.VISIBLE);
1331                    }
1332                }
1333            }
1334
1335            final int TYPE_ERROR = 1;
1336            final int TYPE_NOTE = 2;
1337
1338            protected String mTitle;
1339            protected CommandPageBinding mBinding = null;
1340            protected IqPacket response = null;
1341
1342            CommandSession(String title) {
1343                mTitle = title;
1344            }
1345
1346            public String getTitle() {
1347                return mTitle;
1348            }
1349
1350            public void updateWithResponse(IqPacket iq) {
1351                this.response = iq;
1352                notifyDataSetChanged();
1353            }
1354
1355            @Override
1356            public int getItemCount() {
1357                if (response == null) return 0;
1358                return 1;
1359            }
1360
1361            @Override
1362            public int getItemViewType(int position) {
1363                if (response == null) return -1;
1364
1365                if (response.getType() == IqPacket.TYPE.RESULT) {
1366                    return TYPE_NOTE;
1367                } else {
1368                    return TYPE_ERROR;
1369                }
1370            }
1371
1372            @Override
1373            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
1374                switch(viewType) {
1375                    case TYPE_ERROR: {
1376                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1377                        return new ErrorViewHolder(binding);
1378                    }
1379                    case TYPE_NOTE: {
1380                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1381                        return new NoteViewHolder(binding);
1382                    }
1383                    default:
1384                        return null;
1385                }
1386            }
1387
1388            @Override
1389            public void onBindViewHolder(ViewHolder viewHolder, int position) {
1390                viewHolder.bind(response, position);
1391            }
1392
1393            public View getView() {
1394                return mBinding.getRoot();
1395            }
1396
1397            public void setBinding(CommandPageBinding b) {
1398                mBinding = b;
1399            }
1400        }
1401    }
1402}