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