Conversation.java

   1package eu.siacs.conversations.entities;
   2
   3import android.content.ContentValues;
   4import android.database.Cursor;
   5import android.database.DataSetObserver;
   6import android.net.Uri;
   7import android.text.Editable;
   8import android.text.InputType;
   9import android.text.TextUtils;
  10import android.text.TextWatcher;
  11import android.view.LayoutInflater;
  12import android.view.Gravity;
  13import android.view.View;
  14import android.view.ViewGroup;
  15import android.widget.ArrayAdapter;
  16import android.widget.CompoundButton;
  17import android.widget.TextView;
  18import android.webkit.WebView;
  19import android.webkit.WebViewClient;
  20
  21import androidx.annotation.NonNull;
  22import androidx.annotation.Nullable;
  23import androidx.core.content.ContextCompat;
  24import androidx.databinding.DataBindingUtil;
  25import androidx.databinding.ViewDataBinding;
  26import androidx.viewpager.widget.PagerAdapter;
  27import androidx.recyclerview.widget.RecyclerView;
  28import androidx.recyclerview.widget.LinearLayoutManager;
  29import androidx.viewpager.widget.ViewPager;
  30
  31import com.google.android.material.tabs.TabLayout;
  32import com.google.common.collect.ComparisonChain;
  33import com.google.common.collect.Lists;
  34
  35import org.json.JSONArray;
  36import org.json.JSONException;
  37import org.json.JSONObject;
  38
  39import java.util.ArrayList;
  40import java.util.Collections;
  41import java.util.Iterator;
  42import java.util.List;
  43import java.util.ListIterator;
  44import java.util.concurrent.atomic.AtomicBoolean;
  45
  46import eu.siacs.conversations.Config;
  47import eu.siacs.conversations.R;
  48import eu.siacs.conversations.crypto.OmemoSetting;
  49import eu.siacs.conversations.crypto.PgpDecryptionService;
  50import eu.siacs.conversations.databinding.CommandPageBinding;
  51import eu.siacs.conversations.databinding.CommandNoteBinding;
  52import eu.siacs.conversations.databinding.CommandResultFieldBinding;
  53import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
  54import eu.siacs.conversations.databinding.CommandTextFieldBinding;
  55import eu.siacs.conversations.databinding.CommandWebviewBinding;
  56import eu.siacs.conversations.persistance.DatabaseBackend;
  57import eu.siacs.conversations.services.AvatarService;
  58import eu.siacs.conversations.services.QuickConversationsService;
  59import eu.siacs.conversations.services.XmppConnectionService;
  60import eu.siacs.conversations.utils.JidHelper;
  61import eu.siacs.conversations.utils.MessageUtils;
  62import eu.siacs.conversations.utils.UIHelper;
  63import eu.siacs.conversations.xml.Element;
  64import eu.siacs.conversations.xml.Namespace;
  65import eu.siacs.conversations.xmpp.Jid;
  66import eu.siacs.conversations.xmpp.chatstate.ChatState;
  67import eu.siacs.conversations.xmpp.mam.MamReference;
  68import eu.siacs.conversations.xmpp.stanzas.IqPacket;
  69
  70import static eu.siacs.conversations.entities.Bookmark.printableValue;
  71
  72
  73public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
  74    public static final String TABLENAME = "conversations";
  75
  76    public static final int STATUS_AVAILABLE = 0;
  77    public static final int STATUS_ARCHIVED = 1;
  78
  79    public static final String NAME = "name";
  80    public static final String ACCOUNT = "accountUuid";
  81    public static final String CONTACT = "contactUuid";
  82    public static final String CONTACTJID = "contactJid";
  83    public static final String STATUS = "status";
  84    public static final String CREATED = "created";
  85    public static final String MODE = "mode";
  86    public static final String ATTRIBUTES = "attributes";
  87
  88    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
  89    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
  90    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
  91    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
  92    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
  93    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
  94    static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
  95    static final String ATTRIBUTE_MODERATED = "moderated";
  96    static final String ATTRIBUTE_NON_ANONYMOUS = "non_anonymous";
  97    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
  98    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
  99    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
 100    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
 101    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
 102    protected final ArrayList<Message> messages = new ArrayList<>();
 103    public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
 104    protected Account account = null;
 105    private String draftMessage;
 106    private final String name;
 107    private final String contactUuid;
 108    private final String accountUuid;
 109    private Jid contactJid;
 110    private int status;
 111    private final long created;
 112    private int mode;
 113    private JSONObject attributes;
 114    private Jid nextCounterpart;
 115    private transient MucOptions mucOptions = null;
 116    private boolean messagesLeftOnServer = true;
 117    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
 118    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
 119    private String mFirstMamReference = null;
 120    protected int mCurrentTab = -1;
 121    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
 122
 123    public Conversation(final String name, final Account account, final Jid contactJid,
 124                        final int mode) {
 125        this(java.util.UUID.randomUUID().toString(), name, null, account
 126                        .getUuid(), contactJid, System.currentTimeMillis(),
 127                STATUS_AVAILABLE, mode, "");
 128        this.account = account;
 129    }
 130
 131    public Conversation(final String uuid, final String name, final String contactUuid,
 132                        final String accountUuid, final Jid contactJid, final long created, final int status,
 133                        final int mode, final String attributes) {
 134        this.uuid = uuid;
 135        this.name = name;
 136        this.contactUuid = contactUuid;
 137        this.accountUuid = accountUuid;
 138        this.contactJid = contactJid;
 139        this.created = created;
 140        this.status = status;
 141        this.mode = mode;
 142        try {
 143            this.attributes = new JSONObject(attributes == null ? "" : attributes);
 144        } catch (JSONException e) {
 145            this.attributes = new JSONObject();
 146        }
 147    }
 148
 149    public static Conversation fromCursor(Cursor cursor) {
 150        return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
 151                cursor.getString(cursor.getColumnIndex(NAME)),
 152                cursor.getString(cursor.getColumnIndex(CONTACT)),
 153                cursor.getString(cursor.getColumnIndex(ACCOUNT)),
 154                JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
 155                cursor.getLong(cursor.getColumnIndex(CREATED)),
 156                cursor.getInt(cursor.getColumnIndex(STATUS)),
 157                cursor.getInt(cursor.getColumnIndex(MODE)),
 158                cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
 159    }
 160
 161    public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
 162        for (int i = messages.size() - 1; i >= 0; --i) {
 163            final Message message = messages.get(i);
 164            if (message.getStatus() <= Message.STATUS_RECEIVED
 165                    && (message.markable || isPrivateAndNonAnonymousMuc)
 166                    && !message.isPrivateMessage()) {
 167                return message;
 168            }
 169        }
 170        return null;
 171    }
 172
 173    private static boolean suitableForOmemoByDefault(final Conversation conversation) {
 174        if (conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)) {
 175            return false;
 176        }
 177        if (conversation.getContact().isOwnServer()) {
 178            return false;
 179        }
 180        final String contact = conversation.getJid().getDomain().toEscapedString();
 181        final String account = conversation.getAccount().getServer();
 182        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
 183            return false;
 184        }
 185        return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
 186    }
 187
 188    public boolean hasMessagesLeftOnServer() {
 189        return messagesLeftOnServer;
 190    }
 191
 192    public void setHasMessagesLeftOnServer(boolean value) {
 193        this.messagesLeftOnServer = value;
 194    }
 195
 196    public Message getFirstUnreadMessage() {
 197        Message first = null;
 198        synchronized (this.messages) {
 199            for (int i = messages.size() - 1; i >= 0; --i) {
 200                if (messages.get(i).isRead()) {
 201                    return first;
 202                } else {
 203                    first = messages.get(i);
 204                }
 205            }
 206        }
 207        return first;
 208    }
 209
 210    public String findMostRecentRemoteDisplayableId() {
 211        final boolean multi = mode == Conversation.MODE_MULTI;
 212        synchronized (this.messages) {
 213            for (final Message message : Lists.reverse(this.messages)) {
 214                if (message.getStatus() == Message.STATUS_RECEIVED) {
 215                    final String serverMsgId = message.getServerMsgId();
 216                    if (serverMsgId != null && multi) {
 217                        return serverMsgId;
 218                    }
 219                    return message.getRemoteMsgId();
 220                }
 221            }
 222        }
 223        return null;
 224    }
 225
 226    public int countFailedDeliveries() {
 227        int count = 0;
 228        synchronized (this.messages) {
 229            for(final Message message : this.messages) {
 230                if (message.getStatus() == Message.STATUS_SEND_FAILED) {
 231                    ++count;
 232                }
 233            }
 234        }
 235        return count;
 236    }
 237
 238    public Message getLastEditableMessage() {
 239        synchronized (this.messages) {
 240            for (final Message message : Lists.reverse(this.messages)) {
 241                if (message.isEditable()) {
 242                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
 243                        return null;
 244                    }
 245                    return message;
 246                }
 247            }
 248        }
 249        return null;
 250    }
 251
 252
 253    public Message findUnsentMessageWithUuid(String uuid) {
 254        synchronized (this.messages) {
 255            for (final Message message : this.messages) {
 256                final int s = message.getStatus();
 257                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
 258                    return message;
 259                }
 260            }
 261        }
 262        return null;
 263    }
 264
 265    public void findWaitingMessages(OnMessageFound onMessageFound) {
 266        final ArrayList<Message> results = new ArrayList<>();
 267        synchronized (this.messages) {
 268            for (Message message : this.messages) {
 269                if (message.getStatus() == Message.STATUS_WAITING) {
 270                    results.add(message);
 271                }
 272            }
 273        }
 274        for (Message result : results) {
 275            onMessageFound.onMessageFound(result);
 276        }
 277    }
 278
 279    public void findUnreadMessagesAndCalls(OnMessageFound onMessageFound) {
 280        final ArrayList<Message> results = new ArrayList<>();
 281        synchronized (this.messages) {
 282            for (final Message message : this.messages) {
 283                if (message.isRead()) {
 284                    continue;
 285                }
 286                results.add(message);
 287            }
 288        }
 289        for (final Message result : results) {
 290            onMessageFound.onMessageFound(result);
 291        }
 292    }
 293
 294    public Message findMessageWithFileAndUuid(final String uuid) {
 295        synchronized (this.messages) {
 296            for (final Message message : this.messages) {
 297                final Transferable transferable = message.getTransferable();
 298                final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
 299                if (message.getUuid().equals(uuid)
 300                        && message.getEncryption() != Message.ENCRYPTION_PGP
 301                        && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
 302                    return message;
 303                }
 304            }
 305        }
 306        return null;
 307    }
 308
 309    public Message findMessageWithUuid(final String uuid) {
 310        synchronized (this.messages) {
 311            for (final Message message : this.messages) {
 312                if (message.getUuid().equals(uuid)) {
 313                    return message;
 314                }
 315            }
 316        }
 317        return null;
 318    }
 319
 320    public boolean markAsDeleted(final List<String> uuids) {
 321        boolean deleted = false;
 322        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 323        synchronized (this.messages) {
 324            for (Message message : this.messages) {
 325                if (uuids.contains(message.getUuid())) {
 326                    message.setDeleted(true);
 327                    deleted = true;
 328                    if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 329                        pgpDecryptionService.discard(message);
 330                    }
 331                }
 332            }
 333        }
 334        return deleted;
 335    }
 336
 337    public boolean markAsChanged(final List<DatabaseBackend.FilePathInfo> files) {
 338        boolean changed = false;
 339        final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 340        synchronized (this.messages) {
 341            for (Message message : this.messages) {
 342                for (final DatabaseBackend.FilePathInfo file : files)
 343                    if (file.uuid.toString().equals(message.getUuid())) {
 344                        message.setDeleted(file.deleted);
 345                        changed = true;
 346                        if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
 347                            pgpDecryptionService.discard(message);
 348                        }
 349                    }
 350            }
 351        }
 352        return changed;
 353    }
 354
 355    public void clearMessages() {
 356        synchronized (this.messages) {
 357            this.messages.clear();
 358        }
 359    }
 360
 361    public boolean setIncomingChatState(ChatState state) {
 362        if (this.mIncomingChatState == state) {
 363            return false;
 364        }
 365        this.mIncomingChatState = state;
 366        return true;
 367    }
 368
 369    public ChatState getIncomingChatState() {
 370        return this.mIncomingChatState;
 371    }
 372
 373    public boolean setOutgoingChatState(ChatState state) {
 374        if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
 375            if (this.mOutgoingChatState != state) {
 376                this.mOutgoingChatState = state;
 377                return true;
 378            }
 379        }
 380        return false;
 381    }
 382
 383    public ChatState getOutgoingChatState() {
 384        return this.mOutgoingChatState;
 385    }
 386
 387    public void trim() {
 388        synchronized (this.messages) {
 389            final int size = messages.size();
 390            final int maxsize = Config.PAGE_SIZE * Config.MAX_NUM_PAGES;
 391            if (size > maxsize) {
 392                List<Message> discards = this.messages.subList(0, size - maxsize);
 393                final PgpDecryptionService pgpDecryptionService = account.getPgpDecryptionService();
 394                if (pgpDecryptionService != null) {
 395                    pgpDecryptionService.discard(discards);
 396                }
 397                discards.clear();
 398                untieMessages();
 399            }
 400        }
 401    }
 402
 403    public void findUnsentTextMessages(OnMessageFound onMessageFound) {
 404        final ArrayList<Message> results = new ArrayList<>();
 405        synchronized (this.messages) {
 406            for (Message message : this.messages) {
 407                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
 408                    results.add(message);
 409                }
 410            }
 411        }
 412        for (Message result : results) {
 413            onMessageFound.onMessageFound(result);
 414        }
 415    }
 416
 417    public Message findSentMessageWithUuidOrRemoteId(String id) {
 418        synchronized (this.messages) {
 419            for (Message message : this.messages) {
 420                if (id.equals(message.getUuid())
 421                        || (message.getStatus() >= Message.STATUS_SEND
 422                        && id.equals(message.getRemoteMsgId()))) {
 423                    return message;
 424                }
 425            }
 426        }
 427        return null;
 428    }
 429
 430    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
 431        synchronized (this.messages) {
 432            for (int i = this.messages.size() - 1; i >= 0; --i) {
 433                final Message message = messages.get(i);
 434                final Jid mcp = message.getCounterpart();
 435                if (mcp == null) {
 436                    continue;
 437                }
 438                if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
 439                        && (carbon == message.isCarbon() || received)) {
 440                    final boolean idMatch = id.equals(message.getRemoteMsgId()) || message.remoteMsgIdMatchInEdit(id);
 441                    if (idMatch && !message.isFileOrImage() && !message.treatAsDownloadable()) {
 442                        return message;
 443                    } else {
 444                        return null;
 445                    }
 446                }
 447            }
 448        }
 449        return null;
 450    }
 451
 452    public Message findSentMessageWithUuid(String id) {
 453        synchronized (this.messages) {
 454            for (Message message : this.messages) {
 455                if (id.equals(message.getUuid())) {
 456                    return message;
 457                }
 458            }
 459        }
 460        return null;
 461    }
 462
 463    public Message findMessageWithRemoteId(String id, Jid counterpart) {
 464        synchronized (this.messages) {
 465            for (Message message : this.messages) {
 466                if (counterpart.equals(message.getCounterpart())
 467                        && (id.equals(message.getRemoteMsgId()) || id.equals(message.getUuid()))) {
 468                    return message;
 469                }
 470            }
 471        }
 472        return null;
 473    }
 474
 475    public Message findMessageWithServerMsgId(String id) {
 476        synchronized (this.messages) {
 477            for (Message message : this.messages) {
 478                if (id != null && id.equals(message.getServerMsgId())) {
 479                    return message;
 480                }
 481            }
 482        }
 483        return null;
 484    }
 485
 486    public boolean hasMessageWithCounterpart(Jid counterpart) {
 487        synchronized (this.messages) {
 488            for (Message message : this.messages) {
 489                if (counterpart.equals(message.getCounterpart())) {
 490                    return true;
 491                }
 492            }
 493        }
 494        return false;
 495    }
 496
 497    public void populateWithMessages(final List<Message> messages) {
 498        synchronized (this.messages) {
 499            messages.clear();
 500            messages.addAll(this.messages);
 501        }
 502        for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
 503            if (iterator.next().wasMergedIntoPrevious()) {
 504                iterator.remove();
 505            }
 506        }
 507    }
 508
 509    @Override
 510    public boolean isBlocked() {
 511        return getContact().isBlocked();
 512    }
 513
 514    @Override
 515    public boolean isDomainBlocked() {
 516        return getContact().isDomainBlocked();
 517    }
 518
 519    @Override
 520    public Jid getBlockedJid() {
 521        return getContact().getBlockedJid();
 522    }
 523
 524    public int countMessages() {
 525        synchronized (this.messages) {
 526            return this.messages.size();
 527        }
 528    }
 529
 530    public String getFirstMamReference() {
 531        return this.mFirstMamReference;
 532    }
 533
 534    public void setFirstMamReference(String reference) {
 535        this.mFirstMamReference = reference;
 536    }
 537
 538    public void setLastClearHistory(long time, String reference) {
 539        if (reference != null) {
 540            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
 541        } else {
 542            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
 543        }
 544    }
 545
 546    public MamReference getLastClearHistory() {
 547        return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
 548    }
 549
 550    public List<Jid> getAcceptedCryptoTargets() {
 551        if (mode == MODE_SINGLE) {
 552            return Collections.singletonList(getJid().asBareJid());
 553        } else {
 554            return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
 555        }
 556    }
 557
 558    public void setAcceptedCryptoTargets(List<Jid> acceptedTargets) {
 559        setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
 560    }
 561
 562    public boolean setCorrectingMessage(Message correctingMessage) {
 563        setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
 564        return correctingMessage == null && draftMessage != null;
 565    }
 566
 567    public Message getCorrectingMessage() {
 568        final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
 569        return uuid == null ? null : findSentMessageWithUuid(uuid);
 570    }
 571
 572    public boolean withSelf() {
 573        return getContact().isSelf();
 574    }
 575
 576    @Override
 577    public int compareTo(@NonNull Conversation another) {
 578        return ComparisonChain.start()
 579                .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
 580                .compare(another.getSortableTime(), getSortableTime())
 581                .result();
 582    }
 583
 584    private long getSortableTime() {
 585        Draft draft = getDraft();
 586        long messageTime = getLatestMessage().getTimeSent();
 587        if (draft == null) {
 588            return messageTime;
 589        } else {
 590            return Math.max(messageTime, draft.getTimestamp());
 591        }
 592    }
 593
 594    public String getDraftMessage() {
 595        return draftMessage;
 596    }
 597
 598    public void setDraftMessage(String draftMessage) {
 599        this.draftMessage = draftMessage;
 600    }
 601
 602    public boolean isRead() {
 603        synchronized (this.messages) {
 604            for(final Message message : Lists.reverse(this.messages)) {
 605                if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
 606                    continue;
 607                }
 608                return message.isRead();
 609            }
 610            return true;
 611        }
 612    }
 613
 614    public List<Message> markRead(String upToUuid) {
 615        final List<Message> unread = new ArrayList<>();
 616        synchronized (this.messages) {
 617            for (Message message : this.messages) {
 618                if (!message.isRead()) {
 619                    message.markRead();
 620                    unread.add(message);
 621                }
 622                if (message.getUuid().equals(upToUuid)) {
 623                    return unread;
 624                }
 625            }
 626        }
 627        return unread;
 628    }
 629
 630    public Message getLatestMessage() {
 631        synchronized (this.messages) {
 632            if (this.messages.size() == 0) {
 633                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
 634                message.setType(Message.TYPE_STATUS);
 635                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
 636                return message;
 637            } else {
 638                return this.messages.get(this.messages.size() - 1);
 639            }
 640        }
 641    }
 642
 643    public @NonNull
 644    CharSequence getName() {
 645        if (getMode() == MODE_MULTI) {
 646            final String roomName = getMucOptions().getName();
 647            final String subject = getMucOptions().getSubject();
 648            final Bookmark bookmark = getBookmark();
 649            final String bookmarkName = bookmark != null ? bookmark.getBookmarkName() : null;
 650            if (printableValue(roomName)) {
 651                return roomName;
 652            } else if (printableValue(subject)) {
 653                return subject;
 654            } else if (printableValue(bookmarkName, false)) {
 655                return bookmarkName;
 656            } else {
 657                final String generatedName = getMucOptions().createNameFromParticipants();
 658                if (printableValue(generatedName)) {
 659                    return generatedName;
 660                } else {
 661                    return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
 662                }
 663            }
 664        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
 665            return contactJid;
 666        } else {
 667            return this.getContact().getDisplayName();
 668        }
 669    }
 670
 671    public String getAccountUuid() {
 672        return this.accountUuid;
 673    }
 674
 675    public Account getAccount() {
 676        return this.account;
 677    }
 678
 679    public void setAccount(final Account account) {
 680        this.account = account;
 681    }
 682
 683    public Contact getContact() {
 684        return this.account.getRoster().getContact(this.contactJid);
 685    }
 686
 687    @Override
 688    public Jid getJid() {
 689        return this.contactJid;
 690    }
 691
 692    public int getStatus() {
 693        return this.status;
 694    }
 695
 696    public void setStatus(int status) {
 697        this.status = status;
 698    }
 699
 700    public long getCreated() {
 701        return this.created;
 702    }
 703
 704    public ContentValues getContentValues() {
 705        ContentValues values = new ContentValues();
 706        values.put(UUID, uuid);
 707        values.put(NAME, name);
 708        values.put(CONTACT, contactUuid);
 709        values.put(ACCOUNT, accountUuid);
 710        values.put(CONTACTJID, contactJid.toString());
 711        values.put(CREATED, created);
 712        values.put(STATUS, status);
 713        values.put(MODE, mode);
 714        synchronized (this.attributes) {
 715            values.put(ATTRIBUTES, attributes.toString());
 716        }
 717        return values;
 718    }
 719
 720    public int getMode() {
 721        return this.mode;
 722    }
 723
 724    public void setMode(int mode) {
 725        this.mode = mode;
 726    }
 727
 728    /**
 729     * short for is Private and Non-anonymous
 730     */
 731    public boolean isSingleOrPrivateAndNonAnonymous() {
 732        return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
 733    }
 734
 735    public boolean isPrivateAndNonAnonymous() {
 736        return getMucOptions().isPrivateAndNonAnonymous();
 737    }
 738
 739    public synchronized MucOptions getMucOptions() {
 740        if (this.mucOptions == null) {
 741            this.mucOptions = new MucOptions(this);
 742        }
 743        return this.mucOptions;
 744    }
 745
 746    public void resetMucOptions() {
 747        this.mucOptions = null;
 748    }
 749
 750    public void setContactJid(final Jid jid) {
 751        this.contactJid = jid;
 752    }
 753
 754    public Jid getNextCounterpart() {
 755        return this.nextCounterpart;
 756    }
 757
 758    public void setNextCounterpart(Jid jid) {
 759        this.nextCounterpart = jid;
 760    }
 761
 762    public int getNextEncryption() {
 763        if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
 764            return Message.ENCRYPTION_NONE;
 765        }
 766        if (OmemoSetting.isAlways()) {
 767            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
 768        }
 769        final int defaultEncryption;
 770        if (suitableForOmemoByDefault(this)) {
 771            defaultEncryption = OmemoSetting.getEncryption();
 772        } else {
 773            defaultEncryption = Message.ENCRYPTION_NONE;
 774        }
 775        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
 776        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
 777            return defaultEncryption;
 778        } else {
 779            return encryption;
 780        }
 781    }
 782
 783    public boolean setNextEncryption(int encryption) {
 784        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
 785    }
 786
 787    public String getNextMessage() {
 788        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 789        return nextMessage == null ? "" : nextMessage;
 790    }
 791
 792    public @Nullable
 793    Draft getDraft() {
 794        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
 795        if (timestamp > getLatestMessage().getTimeSent()) {
 796            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
 797            if (!TextUtils.isEmpty(message) && timestamp != 0) {
 798                return new Draft(message, timestamp);
 799            }
 800        }
 801        return null;
 802    }
 803
 804    public boolean setNextMessage(final String input) {
 805        final String message = input == null || input.trim().isEmpty() ? null : input;
 806        boolean changed = !getNextMessage().equals(message);
 807        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
 808        if (changed) {
 809            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
 810        }
 811        return changed;
 812    }
 813
 814    public Bookmark getBookmark() {
 815        return this.account.getBookmark(this.contactJid);
 816    }
 817
 818    public Message findDuplicateMessage(Message message) {
 819        synchronized (this.messages) {
 820            for (int i = this.messages.size() - 1; i >= 0; --i) {
 821                if (this.messages.get(i).similar(message)) {
 822                    return this.messages.get(i);
 823                }
 824            }
 825        }
 826        return null;
 827    }
 828
 829    public boolean hasDuplicateMessage(Message message) {
 830        return findDuplicateMessage(message) != null;
 831    }
 832
 833    public Message findSentMessageWithBody(String body) {
 834        synchronized (this.messages) {
 835            for (int i = this.messages.size() - 1; i >= 0; --i) {
 836                Message message = this.messages.get(i);
 837                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
 838                    String otherBody;
 839                    if (message.hasFileOnRemoteHost()) {
 840                        otherBody = message.getFileParams().url;
 841                    } else {
 842                        otherBody = message.body;
 843                    }
 844                    if (otherBody != null && otherBody.equals(body)) {
 845                        return message;
 846                    }
 847                }
 848            }
 849            return null;
 850        }
 851    }
 852
 853    public Message findRtpSession(final String sessionId, final int s) {
 854        synchronized (this.messages) {
 855            for (int i = this.messages.size() - 1; i >= 0; --i) {
 856                final Message message = this.messages.get(i);
 857                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
 858                    return message;
 859                }
 860            }
 861        }
 862        return null;
 863    }
 864
 865    public boolean possibleDuplicate(final String serverMsgId, final String remoteMsgId) {
 866        if (serverMsgId == null || remoteMsgId == null) {
 867            return false;
 868        }
 869        synchronized (this.messages) {
 870            for (Message message : this.messages) {
 871                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
 872                    return true;
 873                }
 874            }
 875        }
 876        return false;
 877    }
 878
 879    public MamReference getLastMessageTransmitted() {
 880        final MamReference lastClear = getLastClearHistory();
 881        MamReference lastReceived = new MamReference(0);
 882        synchronized (this.messages) {
 883            for (int i = this.messages.size() - 1; i >= 0; --i) {
 884                final Message message = this.messages.get(i);
 885                if (message.isPrivateMessage()) {
 886                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
 887                }
 888                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
 889                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
 890                    break;
 891                }
 892            }
 893        }
 894        return MamReference.max(lastClear, lastReceived);
 895    }
 896
 897    public void setMutedTill(long value) {
 898        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
 899    }
 900
 901    public boolean isMuted() {
 902        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
 903    }
 904
 905    public boolean alwaysNotify() {
 906        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
 907    }
 908
 909    public boolean setAttribute(String key, boolean value) {
 910        return setAttribute(key, String.valueOf(value));
 911    }
 912
 913    private boolean setAttribute(String key, long value) {
 914        return setAttribute(key, Long.toString(value));
 915    }
 916
 917    private boolean setAttribute(String key, int value) {
 918        return setAttribute(key, String.valueOf(value));
 919    }
 920
 921    public boolean setAttribute(String key, String value) {
 922        synchronized (this.attributes) {
 923            try {
 924                if (value == null) {
 925                    if (this.attributes.has(key)) {
 926                        this.attributes.remove(key);
 927                        return true;
 928                    } else {
 929                        return false;
 930                    }
 931                } else {
 932                    final String prev = this.attributes.optString(key, null);
 933                    this.attributes.put(key, value);
 934                    return !value.equals(prev);
 935                }
 936            } catch (JSONException e) {
 937                throw new AssertionError(e);
 938            }
 939        }
 940    }
 941
 942    public boolean setAttribute(String key, List<Jid> jids) {
 943        JSONArray array = new JSONArray();
 944        for (Jid jid : jids) {
 945            array.put(jid.asBareJid().toString());
 946        }
 947        synchronized (this.attributes) {
 948            try {
 949                this.attributes.put(key, array);
 950                return true;
 951            } catch (JSONException e) {
 952                return false;
 953            }
 954        }
 955    }
 956
 957    public String getAttribute(String key) {
 958        synchronized (this.attributes) {
 959            return this.attributes.optString(key, null);
 960        }
 961    }
 962
 963    private List<Jid> getJidListAttribute(String key) {
 964        ArrayList<Jid> list = new ArrayList<>();
 965        synchronized (this.attributes) {
 966            try {
 967                JSONArray array = this.attributes.getJSONArray(key);
 968                for (int i = 0; i < array.length(); ++i) {
 969                    try {
 970                        list.add(Jid.of(array.getString(i)));
 971                    } catch (IllegalArgumentException e) {
 972                        //ignored
 973                    }
 974                }
 975            } catch (JSONException e) {
 976                //ignored
 977            }
 978        }
 979        return list;
 980    }
 981
 982    private int getIntAttribute(String key, int defaultValue) {
 983        String value = this.getAttribute(key);
 984        if (value == null) {
 985            return defaultValue;
 986        } else {
 987            try {
 988                return Integer.parseInt(value);
 989            } catch (NumberFormatException e) {
 990                return defaultValue;
 991            }
 992        }
 993    }
 994
 995    public long getLongAttribute(String key, long defaultValue) {
 996        String value = this.getAttribute(key);
 997        if (value == null) {
 998            return defaultValue;
 999        } else {
1000            try {
1001                return Long.parseLong(value);
1002            } catch (NumberFormatException e) {
1003                return defaultValue;
1004            }
1005        }
1006    }
1007
1008    public boolean getBooleanAttribute(String key, boolean defaultValue) {
1009        String value = this.getAttribute(key);
1010        if (value == null) {
1011            return defaultValue;
1012        } else {
1013            return Boolean.parseBoolean(value);
1014        }
1015    }
1016
1017    public void add(Message message) {
1018        synchronized (this.messages) {
1019            this.messages.add(message);
1020        }
1021    }
1022
1023    public void prepend(int offset, Message message) {
1024        synchronized (this.messages) {
1025            this.messages.add(Math.min(offset, this.messages.size()), message);
1026        }
1027    }
1028
1029    public void addAll(int index, List<Message> messages) {
1030        synchronized (this.messages) {
1031            this.messages.addAll(index, messages);
1032        }
1033        account.getPgpDecryptionService().decrypt(messages);
1034    }
1035
1036    public void expireOldMessages(long timestamp) {
1037        synchronized (this.messages) {
1038            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
1039                if (iterator.next().getTimeSent() < timestamp) {
1040                    iterator.remove();
1041                }
1042            }
1043            untieMessages();
1044        }
1045    }
1046
1047    public void sort() {
1048        synchronized (this.messages) {
1049            Collections.sort(this.messages, (left, right) -> {
1050                if (left.getTimeSent() < right.getTimeSent()) {
1051                    return -1;
1052                } else if (left.getTimeSent() > right.getTimeSent()) {
1053                    return 1;
1054                } else {
1055                    return 0;
1056                }
1057            });
1058            untieMessages();
1059        }
1060    }
1061
1062    private void untieMessages() {
1063        for (Message message : this.messages) {
1064            message.untie();
1065        }
1066    }
1067
1068    public int unreadCount() {
1069        synchronized (this.messages) {
1070            int count = 0;
1071            for(final Message message : Lists.reverse(this.messages)) {
1072                if (message.isRead()) {
1073                    if (message.getType() == Message.TYPE_RTP_SESSION) {
1074                        continue;
1075                    }
1076                    return count;
1077                }
1078                ++count;
1079            }
1080            return count;
1081        }
1082    }
1083
1084    public int receivedMessagesCount() {
1085        int count = 0;
1086        synchronized (this.messages) {
1087            for (Message message : messages) {
1088                if (message.getStatus() == Message.STATUS_RECEIVED) {
1089                    ++count;
1090                }
1091            }
1092        }
1093        return count;
1094    }
1095
1096    public int sentMessagesCount() {
1097        int count = 0;
1098        synchronized (this.messages) {
1099            for (Message message : messages) {
1100                if (message.getStatus() != Message.STATUS_RECEIVED) {
1101                    ++count;
1102                }
1103            }
1104        }
1105        return count;
1106    }
1107
1108    public boolean isWithStranger() {
1109        final Contact contact = getContact();
1110        return mode == MODE_SINGLE
1111                && !contact.isOwnServer()
1112                && !contact.showInContactList()
1113                && !contact.isSelf()
1114                && !(contact.getJid().isDomainJid() && JidHelper.isQuicksyDomain(contact.getJid()))
1115                && sentMessagesCount() == 0;
1116    }
1117
1118    public int getReceivedMessagesCountSinceUuid(String uuid) {
1119        if (uuid == null) {
1120            return 0;
1121        }
1122        int count = 0;
1123        synchronized (this.messages) {
1124            for (int i = messages.size() - 1; i >= 0; i--) {
1125                final Message message = messages.get(i);
1126                if (uuid.equals(message.getUuid())) {
1127                    return count;
1128                }
1129                if (message.getStatus() <= Message.STATUS_RECEIVED) {
1130                    ++count;
1131                }
1132            }
1133        }
1134        return 0;
1135    }
1136
1137    @Override
1138    public int getAvatarBackgroundColor() {
1139        return UIHelper.getColorForName(getName().toString());
1140    }
1141
1142    @Override
1143    public String getAvatarName() {
1144        return getName().toString();
1145    }
1146
1147    public void setCurrentTab(int tab) {
1148        mCurrentTab = tab;
1149    }
1150
1151    public int getCurrentTab() {
1152        if (mCurrentTab >= 0) return mCurrentTab;
1153
1154        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
1155            return 0;
1156        }
1157
1158        return 1;
1159    }
1160
1161    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1162        pagerAdapter.startCommand(command, xmppConnectionService);
1163    }
1164
1165    public void setupViewPager(ViewPager pager, TabLayout tabs) {
1166        pagerAdapter.setupViewPager(pager, tabs);
1167    }
1168
1169    public interface OnMessageFound {
1170        void onMessageFound(final Message message);
1171    }
1172
1173    public static class Draft {
1174        private final String message;
1175        private final long timestamp;
1176
1177        private Draft(String message, long timestamp) {
1178            this.message = message;
1179            this.timestamp = timestamp;
1180        }
1181
1182        public long getTimestamp() {
1183            return timestamp;
1184        }
1185
1186        public String getMessage() {
1187            return message;
1188        }
1189    }
1190
1191    public class ConversationPagerAdapter extends PagerAdapter {
1192        protected ViewPager mPager = null;
1193        protected TabLayout mTabs = null;
1194        ArrayList<CommandSession> sessions = new ArrayList<>();
1195
1196        public void setupViewPager(ViewPager pager, TabLayout tabs) {
1197            mPager = pager;
1198            mTabs = tabs;
1199            pager.setAdapter(this);
1200            tabs.setupWithViewPager(mPager);
1201            pager.setCurrentItem(getCurrentTab());
1202
1203            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
1204                public void onPageScrollStateChanged(int state) { }
1205                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }
1206
1207                public void onPageSelected(int position) {
1208                    setCurrentTab(position);
1209                }
1210            });
1211        }
1212
1213        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
1214            CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);
1215
1216            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1217            packet.setTo(command.getAttributeAsJid("jid"));
1218            final Element c = packet.addChild("command", Namespace.COMMANDS);
1219            c.setAttribute("node", command.getAttribute("node"));
1220            c.setAttribute("action", "execute");
1221            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1222                mPager.post(() -> {
1223                    session.updateWithResponse(iq);
1224                });
1225            });
1226
1227            sessions.add(session);
1228            notifyDataSetChanged();
1229            mPager.setCurrentItem(getCount() - 1);
1230        }
1231
1232        public void removeSession(CommandSession session) {
1233            sessions.remove(session);
1234            notifyDataSetChanged();
1235        }
1236
1237        @NonNull
1238        @Override
1239        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1240            if (position < 2) {
1241              return mPager.getChildAt(position);
1242            }
1243
1244            CommandSession session = sessions.get(position-2);
1245            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1246            container.addView(binding.getRoot());
1247            session.setBinding(binding);
1248            return session;
1249        }
1250
1251        @Override
1252        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1253            if (position < 2) return;
1254
1255            container.removeView(((CommandSession) o).getView());
1256        }
1257
1258        @Override
1259        public int getItemPosition(Object o) {
1260            if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
1261            if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;
1262
1263            int pos = sessions.indexOf(o);
1264            if (pos < 0) return PagerAdapter.POSITION_NONE;
1265            return pos + 2;
1266        }
1267
1268        @Override
1269        public int getCount() {
1270            int count = 2 + sessions.size();
1271            if (count > 2) {
1272                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1273            } else {
1274                mTabs.setTabMode(TabLayout.MODE_FIXED);
1275            }
1276            return count;
1277        }
1278
1279        @Override
1280        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1281            if (view == o) return true;
1282
1283            if (o instanceof CommandSession) {
1284                return ((CommandSession) o).getView() == view;
1285            }
1286
1287            return false;
1288        }
1289
1290        @Nullable
1291        @Override
1292        public CharSequence getPageTitle(int position) {
1293            switch (position) {
1294                case 0:
1295                    return "Conversation";
1296                case 1:
1297                    return "Commands";
1298                default:
1299                    CommandSession session = sessions.get(position-2);
1300                    if (session == null) return super.getPageTitle(position);
1301                    return session.getTitle();
1302            }
1303        }
1304
1305        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1306            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1307                protected T binding;
1308
1309                public ViewHolder(T binding) {
1310                    super(binding.getRoot());
1311                    this.binding = binding;
1312                }
1313
1314                abstract public void bind(Element el);
1315            }
1316
1317            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1318                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1319
1320                @Override
1321                public void bind(Element iq) {
1322                    binding.errorIcon.setVisibility(View.VISIBLE);
1323
1324                    Element error = iq.findChild("error");
1325                    if (error == null) return;
1326                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1327                    if (text == null || text.equals("")) {
1328                        text = error.getChildren().get(0).getName();
1329                    }
1330                    binding.message.setText(text);
1331                }
1332            }
1333
1334            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1335                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1336
1337                @Override
1338                public void bind(Element note) {
1339                    binding.message.setText(note.getContent());
1340
1341                    String type = note.getAttribute("type");
1342                    if (type != null && type.equals("error")) {
1343                        binding.errorIcon.setVisibility(View.VISIBLE);
1344                    }
1345                }
1346            }
1347
1348            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1349                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1350
1351                @Override
1352                public void bind(Element field) {
1353                    String label = field.getAttribute("label");
1354                    if (label == null) label = field.getAttribute("var");
1355                    if (label == null) {
1356                        binding.label.setVisibility(View.GONE);
1357                    } else {
1358                        binding.label.setVisibility(View.VISIBLE);
1359                        binding.label.setText(label);
1360                    }
1361
1362                    String desc = field.findChildContent("desc", "jabber:x:data");
1363                    if (desc == null) {
1364                        binding.desc.setVisibility(View.GONE);
1365                    } else {
1366                        binding.desc.setVisibility(View.VISIBLE);
1367                        binding.desc.setText(desc);
1368                    }
1369
1370                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1371                    for (Element el : field.getChildren()) {
1372                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1373                            values.add(el.getContent());
1374                        }
1375                    }
1376                    binding.values.setAdapter(values);
1377                }
1378            }
1379
1380            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1381                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1382                    super(binding);
1383                    binding.row.setOnClickListener((v) -> {
1384                        binding.checkbox.toggle();
1385                    });
1386                    binding.checkbox.setOnCheckedChangeListener(this);
1387                }
1388                protected Element mValue = null;
1389
1390                @Override
1391                public void bind(Element field) {
1392                    String label = field.getAttribute("label");
1393                    if (label == null) label = field.getAttribute("var");
1394                    if (label == null) label = "";
1395                    binding.label.setText(label);
1396
1397                    String desc = field.findChildContent("desc", "jabber:x:data");
1398                    if (desc == null) {
1399                        binding.desc.setVisibility(View.GONE);
1400                    } else {
1401                        binding.desc.setVisibility(View.VISIBLE);
1402                        binding.desc.setText(desc);
1403                    }
1404
1405                    mValue = field.findChild("value", "jabber:x:data");
1406                    if (mValue == null) {
1407                        mValue = field.addChild("value", "jabber:x:data");
1408                    }
1409
1410                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1411                }
1412
1413                @Override
1414                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1415                    if (mValue == null) return;
1416
1417                    mValue.setContent(isChecked ? "true" : "false");
1418                }
1419            }
1420
1421            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1422                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1423                    super(binding);
1424                    binding.textinput.addTextChangedListener(this);
1425                }
1426                protected Element mValue = null;
1427
1428                @Override
1429                public void bind(Element field) {
1430                    String label = field.getAttribute("label");
1431                    if (label == null) label = field.getAttribute("var");
1432                    if (label == null) label = "";
1433                    binding.textinputLayout.setHint(label);
1434
1435                    String desc = field.findChildContent("desc", "jabber:x:data");
1436                    if (desc == null) {
1437                        binding.desc.setVisibility(View.GONE);
1438                    } else {
1439                        binding.desc.setVisibility(View.VISIBLE);
1440                        binding.desc.setText(desc);
1441                    }
1442
1443                    mValue = field.findChild("value", "jabber:x:data");
1444                    if (mValue == null) {
1445                        mValue = field.addChild("value", "jabber:x:data");
1446                    }
1447                    binding.textinput.setText(mValue.getContent());
1448
1449                    binding.textinput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1450                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1451                    if (validate == null) return;
1452                    String datatype = validate.getAttribute("datatype");
1453
1454                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1455                        binding.textinput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1456                    }
1457
1458                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1459                        binding.textinput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1460                    }
1461
1462                    if (datatype.equals("xs:date")) {
1463                        binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1464                    }
1465
1466                    if (datatype.equals("xs:dateTime")) {
1467                        binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1468                    }
1469
1470                    if (datatype.equals("xs:time")) {
1471                        binding.textinput.setInputType(InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1472                    }
1473
1474                    if (datatype.equals("xs:anyURI")) {
1475                        binding.textinput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1476                    }
1477                }
1478
1479                @Override
1480                public void afterTextChanged(Editable s) {
1481                    if (mValue == null) return;
1482
1483                    mValue.setContent(s.toString());
1484                }
1485
1486                @Override
1487                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1488
1489                @Override
1490                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1491            }
1492
1493            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
1494                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
1495
1496                @Override
1497                public void bind(Element oob) {
1498                    binding.webview.getSettings().setJavaScriptEnabled(true);
1499                    binding.webview.setWebViewClient(new WebViewClient() {
1500                        @Override
1501                        public void onPageFinished(WebView view, String url) {
1502                            super.onPageFinished(view, url);
1503                            mTitle = view.getTitle();
1504                            ConversationPagerAdapter.this.notifyDataSetChanged();
1505                        }
1506                    });
1507                    binding.webview.loadUrl(oob.findChildContent("url", "jabber:x:oob"));
1508                }
1509            }
1510
1511            final int TYPE_ERROR = 1;
1512            final int TYPE_NOTE = 2;
1513            final int TYPE_WEB = 3;
1514            final int TYPE_RESULT_FIELD = 4;
1515            final int TYPE_TEXT_FIELD = 5;
1516            final int TYPE_CHECKBOX_FIELD = 6;
1517
1518            protected String mTitle;
1519            protected CommandPageBinding mBinding = null;
1520            protected IqPacket response = null;
1521            protected Element responseElement = null;
1522            protected XmppConnectionService xmppConnectionService;
1523            protected ArrayAdapter<String> actionsAdapter;
1524
1525            CommandSession(String title, XmppConnectionService xmppConnectionService) {
1526                mTitle = title;
1527                this.xmppConnectionService = xmppConnectionService;
1528                actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
1529                    @Override
1530                    public View getView(int position, View convertView, ViewGroup parent) {
1531                        View v = super.getView(position, convertView, parent);
1532                        TextView tv = (TextView) v.findViewById(android.R.id.text1);
1533                        tv.setGravity(Gravity.CENTER);
1534                        int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
1535                        if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
1536                        tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
1537                        tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
1538                        return v;
1539                    }
1540                };
1541                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
1542                    @Override
1543                    public void onChanged() {
1544                        if (mBinding == null) return;
1545
1546                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
1547                    }
1548
1549                    @Override
1550                    public void onInvalidated() {}
1551                });
1552            }
1553
1554            public String getTitle() {
1555                return mTitle;
1556            }
1557
1558            public void updateWithResponse(IqPacket iq) {
1559                this.responseElement = null;
1560                this.response = iq;
1561                this.actionsAdapter.clear();
1562
1563                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
1564                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
1565                    for (Element el : command.getChildren()) {
1566                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
1567                            for (Element action : el.getChildren()) {
1568                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
1569                                if (action.getName().equals("execute")) continue;
1570
1571                                actionsAdapter.add(action.getName());
1572                            }
1573                        }
1574                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
1575                            String title = el.findChildContent("title", "jabber:x:data");
1576                            if (title != null) {
1577                                mTitle = title;
1578                                ConversationPagerAdapter.this.notifyDataSetChanged();
1579                            }
1580                            this.responseElement = el;
1581                            break;
1582                        }
1583                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
1584                            String url = el.findChildContent("url", "jabber:x:oob");
1585                            if (url != null) {
1586                                String scheme = Uri.parse(url).getScheme();
1587                                if (scheme.equals("http") || scheme.equals("https")) {
1588                                    this.responseElement = el;
1589                                    break;
1590                                }
1591                            }
1592                        }
1593                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
1594                            this.responseElement = el;
1595                            break;
1596                        }
1597                    }
1598
1599                    if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
1600                        removeSession(this);
1601                        return;
1602                    }
1603                }
1604
1605                if (actionsAdapter.getCount() > 0) {
1606                    if (actionsAdapter.getPosition("cancel") < 0) actionsAdapter.insert("cancel", 0);
1607                } else {
1608                    actionsAdapter.add("close");
1609                }
1610
1611                notifyDataSetChanged();
1612            }
1613
1614            @Override
1615            public int getItemCount() {
1616                if (response == null) return 0;
1617                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
1618                    int i = 0;
1619                    for (Element el : responseElement.getChildren()) {
1620                        if (!el.getNamespace().equals("jabber:x:data")) continue;
1621                        if (el.getName().equals("title")) continue;
1622                        if (el.getName().equals("field")) {
1623                            String type = el.getAttribute("type");
1624                            if (type != null && type.equals("hidden")) continue;
1625                        }
1626
1627                        i++;
1628                    }
1629                    return i;
1630                }
1631                return 1;
1632            }
1633
1634            public Element getItem(int position) {
1635                if (response == null) return null;
1636
1637                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
1638                    if (responseElement.getNamespace().equals("jabber:x:data")) {
1639                        int i = 0;
1640                        for (Element el : responseElement.getChildren()) {
1641                            if (!el.getNamespace().equals("jabber:x:data")) continue;
1642                            if (el.getName().equals("title")) continue;
1643                            if (el.getName().equals("field")) {
1644                                String type = el.getAttribute("type");
1645                                if (type != null && type.equals("hidden")) continue;
1646                            }
1647
1648                            if (i < position) {
1649                                i++;
1650                                continue;
1651                            }
1652
1653                            return el;
1654                        }
1655                    }
1656                }
1657
1658                return responseElement == null ? response : responseElement;
1659            }
1660
1661            @Override
1662            public int getItemViewType(int position) {
1663                if (response == null) return -1;
1664
1665                if (response.getType() == IqPacket.TYPE.RESULT) {
1666                    Element item = getItem(position);
1667                    if (item.getName().equals("note")) return TYPE_NOTE;
1668                    if (item.getNamespace().equals("jabber:x:oob")) return TYPE_WEB;
1669                    if (item.getName().equals("instructions") && item.getNamespace().equals("jabber:x:data")) return TYPE_NOTE;
1670                    if (item.getName().equals("field") && item.getNamespace().equals("jabber:x:data")) {
1671                        String formType = responseElement.getAttribute("type");
1672                        String fieldType = item.getAttribute("type");
1673                        if (formType.equals("result") || fieldType.equals("fixed")) return TYPE_RESULT_FIELD;
1674                        if (formType.equals("form")) {
1675                            if (fieldType.equals("boolean")) return TYPE_CHECKBOX_FIELD;
1676
1677                            return TYPE_TEXT_FIELD;
1678                        }
1679                    }
1680                    return -1;
1681                } else {
1682                    return TYPE_ERROR;
1683                }
1684            }
1685
1686            @Override
1687            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
1688                switch(viewType) {
1689                    case TYPE_ERROR: {
1690                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1691                        return new ErrorViewHolder(binding);
1692                    }
1693                    case TYPE_NOTE: {
1694                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
1695                        return new NoteViewHolder(binding);
1696                    }
1697                    case TYPE_WEB: {
1698                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
1699                        return new WebViewHolder(binding);
1700                    }
1701                    case TYPE_RESULT_FIELD: {
1702                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
1703                        return new ResultFieldViewHolder(binding);
1704                    }
1705                    case TYPE_CHECKBOX_FIELD: {
1706                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
1707                        return new CheckboxFieldViewHolder(binding);
1708                    }
1709                    case TYPE_TEXT_FIELD: {
1710                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
1711                        return new TextFieldViewHolder(binding);
1712                    }
1713                    default:
1714                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
1715                }
1716            }
1717
1718            @Override
1719            public void onBindViewHolder(ViewHolder viewHolder, int position) {
1720                viewHolder.bind(getItem(position));
1721            }
1722
1723            public View getView() {
1724                return mBinding.getRoot();
1725            }
1726
1727            public boolean execute() {
1728                return execute("execute");
1729            }
1730
1731            public boolean execute(int actionPosition) {
1732                return execute(actionsAdapter.getItem(actionPosition));
1733            }
1734
1735            public boolean execute(String action) {
1736                if (response == null || responseElement == null) return true;
1737                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
1738                if (command == null) return true;
1739                String status = command.getAttribute("status");
1740                if (status == null || !status.equals("executing")) return true;
1741                if (!responseElement.getName().equals("x") || !responseElement.getNamespace().equals("jabber:x:data")) return true;
1742                String formType = responseElement.getAttribute("type");
1743                if (formType == null || !formType.equals("form")) return true;
1744
1745                responseElement.setAttribute("type", "submit");
1746
1747                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
1748                packet.setTo(response.getFrom());
1749                final Element c = packet.addChild("command", Namespace.COMMANDS);
1750                c.setAttribute("node", command.getAttribute("node"));
1751                c.setAttribute("sessionid", command.getAttribute("sessionid"));
1752                c.setAttribute("action", action);
1753                c.addChild(responseElement);
1754
1755                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1756                    getView().post(() -> {
1757                        updateWithResponse(iq);
1758                    });
1759                });
1760
1761                return false;
1762            }
1763
1764            public void setBinding(CommandPageBinding b) {
1765                mBinding = b;
1766                mBinding.form.setLayoutManager(new LinearLayoutManager(mPager.getContext()) {
1767                    @Override
1768                    public boolean canScrollVertically() { return getItemCount() > 1; }
1769                });
1770                mBinding.form.setAdapter(this);
1771                mBinding.actions.setAdapter(actionsAdapter);
1772                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
1773                    if (execute(pos)) {
1774                        removeSession(CommandSession.this);
1775                    }
1776                });
1777
1778                actionsAdapter.notifyDataSetChanged();
1779            }
1780        }
1781    }
1782}