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