Conversation.java

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