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