Conversation.java

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