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