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                                loading = true;
1865                                mValue.setContent(getItem(position).getValue());
1866                                execute();
1867                            });
1868
1869                            final SVG icon = getItem(position).getIcon();
1870                            if (icon != null) {
1871                                 v.post(() -> {
1872                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1873                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
1874                                     Canvas bmcanvas = new Canvas(bitmap);
1875                                     icon.renderToCanvas(bmcanvas);
1876                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
1877                                 });
1878                            }
1879
1880                            return v;
1881                        }
1882                    };
1883                }
1884                protected Element mValue = null;
1885                protected ArrayAdapter<Option> options;
1886                protected Option defaultOption = null;
1887
1888                @Override
1889                public void bind(Item item) {
1890                    Field field = (Field) item;
1891                    setTextOrHide(binding.label, field.getLabel());
1892                    setTextOrHide(binding.desc, field.getDesc());
1893
1894                    if (field.error != null) {
1895                        binding.desc.setVisibility(View.VISIBLE);
1896                        binding.desc.setText(field.error);
1897                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1898                    } else {
1899                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1900                    }
1901
1902                    mValue = field.getValue();
1903
1904                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1905                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1906                    binding.openButton.setOnClickListener((view) -> {
1907                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
1908                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
1909                        builder.setPositiveButton(R.string.action_execute, null);
1910                        if (field.getDesc().isPresent()) {
1911                            dialogBinding.inputLayout.setHint(field.getDesc().get());
1912                        }
1913                        dialogBinding.inputEditText.requestFocus();
1914                        dialogBinding.inputEditText.getText().append(mValue.getContent());
1915                        builder.setView(dialogBinding.getRoot());
1916                        builder.setNegativeButton(R.string.cancel, null);
1917                        final AlertDialog dialog = builder.create();
1918                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
1919                        dialog.show();
1920                        View.OnClickListener clickListener = v -> {
1921                            loading = true;
1922                            String value = dialogBinding.inputEditText.getText().toString();
1923                            mValue.setContent(value);
1924                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1925                            dialog.dismiss();
1926                            execute();
1927                        };
1928                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
1929                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
1930                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1931                            dialog.dismiss();
1932                        }));
1933                        dialog.setCanceledOnTouchOutside(false);
1934                        dialog.setOnDismissListener(dialog1 -> {
1935                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1936                        });
1937                    });
1938
1939                    options.clear();
1940                    List<Option> theOptions = field.getType().equals(Optional.of("boolean")) ? new ArrayList<>(List.of(new Option("false", binding.getRoot().getContext().getString(R.string.no)), new Option("true", binding.getRoot().getContext().getString(R.string.yes)))) : field.getOptions();
1941
1942                    defaultOption = null;
1943                    for (Option option : theOptions) {
1944                        if (option.getValue().equals(mValue.getContent())) {
1945                            defaultOption = option;
1946                            break;
1947                        }
1948                    }
1949                    if (defaultOption == null && !mValue.getContent().equals("")) {
1950                        // Synthesize default option for custom value
1951                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
1952                    }
1953                    if (defaultOption == null) {
1954                        binding.defaultButton.setVisibility(View.GONE);
1955                    } else {
1956                        theOptions.remove(defaultOption);
1957                        binding.defaultButton.setVisibility(View.VISIBLE);
1958
1959                        final SVG defaultIcon = defaultOption.getIcon();
1960                        if (defaultIcon != null) {
1961                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1962                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
1963                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
1964                             bitmap.setDensity(display.densityDpi);
1965                             Canvas bmcanvas = new Canvas(bitmap);
1966                             defaultIcon.renderToCanvas(bmcanvas);
1967                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
1968                        }
1969
1970                        binding.defaultButton.setText(defaultOption.toString());
1971                        binding.defaultButton.setOnClickListener((view) -> {
1972                            loading = true;
1973                            mValue.setContent(defaultOption.getValue());
1974                            execute();
1975                        });
1976                    }
1977
1978                    options.addAll(theOptions);
1979                    binding.buttons.setAdapter(options);
1980                }
1981            }
1982
1983            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
1984                public TextFieldViewHolder(CommandTextFieldBinding binding) {
1985                    super(binding);
1986                    binding.textinput.addTextChangedListener(this);
1987                }
1988                protected Element mValue = null;
1989
1990                @Override
1991                public void bind(Item item) {
1992                    Field field = (Field) item;
1993                    binding.textinputLayout.setHint(field.getLabel().or(""));
1994
1995                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
1996                    for (String desc : field.getDesc().asSet()) {
1997                        binding.textinputLayout.setHelperText(desc);
1998                    }
1999
2000                    binding.textinputLayout.setErrorEnabled(field.error != null);
2001                    if (field.error != null) binding.textinputLayout.setError(field.error);
2002
2003                    mValue = field.getValue();
2004                    binding.textinput.setText(mValue.getContent());
2005                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2006                }
2007
2008                @Override
2009                public void afterTextChanged(Editable s) {
2010                    if (mValue == null) return;
2011
2012                    mValue.setContent(s.toString());
2013                }
2014
2015                @Override
2016                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2017
2018                @Override
2019                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2020            }
2021
2022            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2023                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2024                protected String boundUrl = "";
2025
2026                @Override
2027                public void bind(Item oob) {
2028                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2029                    binding.webview.getSettings().setJavaScriptEnabled(true);
2030                    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");
2031                    binding.webview.getSettings().setDatabaseEnabled(true);
2032                    binding.webview.getSettings().setDomStorageEnabled(true);
2033                    binding.webview.setWebChromeClient(new WebChromeClient() {
2034                        @Override
2035                        public void onProgressChanged(WebView view, int newProgress) {
2036                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2037                            binding.progressbar.setProgress(newProgress);
2038                        }
2039                    });
2040                    binding.webview.setWebViewClient(new WebViewClient() {
2041                        @Override
2042                        public void onPageFinished(WebView view, String url) {
2043                            super.onPageFinished(view, url);
2044                            mTitle = view.getTitle();
2045                            ConversationPagerAdapter.this.notifyDataSetChanged();
2046                        }
2047                    });
2048                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2049                    if (!boundUrl.equals(url)) {
2050                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2051                        binding.webview.loadUrl(url);
2052                        boundUrl = url;
2053                    }
2054                }
2055
2056                class JsObject {
2057                    @JavascriptInterface
2058                    public void execute() { execute("execute"); }
2059
2060                    @JavascriptInterface
2061                    public void execute(String action) {
2062                        getView().post(() -> {
2063                            actionToWebview = null;
2064                            if(CommandSession.this.execute(action)) {
2065                                removeSession(CommandSession.this);
2066                            }
2067                        });
2068                    }
2069
2070                    @JavascriptInterface
2071                    public void preventDefault() {
2072                        actionToWebview = binding.webview;
2073                    }
2074                }
2075            }
2076
2077            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2078                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2079
2080                @Override
2081                public void bind(Item item) { }
2082            }
2083
2084            class Item {
2085                protected Element el;
2086                protected int viewType;
2087                protected String error = null;
2088
2089                Item(Element el, int viewType) {
2090                    this.el = el;
2091                    this.viewType = viewType;
2092                }
2093
2094                public boolean validate() {
2095                    error = null;
2096                    return true;
2097                }
2098            }
2099
2100            class Field extends Item {
2101                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2102
2103                @Override
2104                public boolean validate() {
2105                    if (!super.validate()) return false;
2106                    if (el.findChild("required", "jabber:x:data") == null) return true;
2107                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2108
2109                    error = "this value is required";
2110                    return false;
2111                }
2112
2113                public String getVar() {
2114                    return el.getAttribute("var");
2115                }
2116
2117                public Optional<String> getType() {
2118                    return Optional.fromNullable(el.getAttribute("type"));
2119                }
2120
2121                public Optional<String> getLabel() {
2122                    String label = el.getAttribute("label");
2123                    if (label == null) label = getVar();
2124                    return Optional.fromNullable(label);
2125                }
2126
2127                public Optional<String> getDesc() {
2128                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2129                }
2130
2131                public Element getValue() {
2132                    Element value = el.findChild("value", "jabber:x:data");
2133                    if (value == null) {
2134                        value = el.addChild("value", "jabber:x:data");
2135                    }
2136                    return value;
2137                }
2138
2139                public List<Option> getOptions() {
2140                    return Option.forField(el);
2141                }
2142            }
2143
2144            class Cell extends Item {
2145                protected Field reported;
2146
2147                Cell(Field reported, Element item) {
2148                    super(item, TYPE_RESULT_CELL);
2149                    this.reported = reported;
2150                }
2151            }
2152
2153            protected Field mkField(Element el) {
2154                int viewType = -1;
2155
2156                String formType = responseElement.getAttribute("type");
2157                if (formType != null) {
2158                    String fieldType = el.getAttribute("type");
2159                    if (fieldType == null) fieldType = "text-single";
2160
2161                    if (formType.equals("result") || fieldType.equals("fixed")) {
2162                        viewType = TYPE_RESULT_FIELD;
2163                    } else if (formType.equals("form")) {
2164                        if (fieldType.equals("boolean")) {
2165                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2166                                viewType = TYPE_BUTTON_GRID_FIELD;
2167                            } else {
2168                                viewType = TYPE_CHECKBOX_FIELD;
2169                            }
2170                        } else if (fieldType.equals("list-single")) {
2171                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2172                            if (Option.forField(el).size() > 9) {
2173                                viewType = TYPE_SEARCH_LIST_FIELD;
2174                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2175                                viewType = TYPE_BUTTON_GRID_FIELD;
2176                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2177                                viewType = TYPE_RADIO_EDIT_FIELD;
2178                            } else {
2179                                viewType = TYPE_SPINNER_FIELD;
2180                            }
2181                        } else {
2182                            viewType = TYPE_TEXT_FIELD;
2183                        }
2184                    }
2185
2186                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2187                }
2188
2189                return null;
2190            }
2191
2192            protected Item mkItem(Element el, int pos) {
2193                int viewType = -1;
2194
2195                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2196                    if (el.getName().equals("note")) {
2197                        viewType = TYPE_NOTE;
2198                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2199                        viewType = TYPE_WEB;
2200                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2201                        viewType = TYPE_NOTE;
2202                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2203                        Field field = mkField(el);
2204                        if (field != null) {
2205                            items.put(pos, field);
2206                            return field;
2207                        }
2208                    }
2209                } else if (response != null) {
2210                    viewType = TYPE_ERROR;
2211                }
2212
2213                Item item = new Item(el, viewType);
2214                items.put(pos, item);
2215                return item;
2216            }
2217
2218            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2219                protected Context ctx;
2220
2221                public ActionsAdapter(Context ctx) {
2222                    super(ctx, R.layout.simple_list_item);
2223                    this.ctx = ctx;
2224                }
2225
2226                @Override
2227                public View getView(int position, View convertView, ViewGroup parent) {
2228                    View v = super.getView(position, convertView, parent);
2229                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2230                    tv.setGravity(Gravity.CENTER);
2231                    tv.setText(getItem(position).second);
2232                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2233                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2234                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2235                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2236                    return v;
2237                }
2238
2239                public int getPosition(String s) {
2240                    for(int i = 0; i < getCount(); i++) {
2241                        if (getItem(i).first.equals(s)) return i;
2242                    }
2243                    return -1;
2244                }
2245
2246                public int countExceptCancel() {
2247                    int count = 0;
2248                    for(int i = 0; i < getCount(); i++) {
2249                        if (!getItem(i).first.equals("cancel")) count++;
2250                    }
2251                    return count;
2252                }
2253
2254                public void clearExceptCancel() {
2255                    Pair<String,String> cancelItem = null;
2256                    for(int i = 0; i < getCount(); i++) {
2257                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2258                    }
2259                    clear();
2260                    if (cancelItem != null) add(cancelItem);
2261                }
2262            }
2263
2264            final int TYPE_ERROR = 1;
2265            final int TYPE_NOTE = 2;
2266            final int TYPE_WEB = 3;
2267            final int TYPE_RESULT_FIELD = 4;
2268            final int TYPE_TEXT_FIELD = 5;
2269            final int TYPE_CHECKBOX_FIELD = 6;
2270            final int TYPE_SPINNER_FIELD = 7;
2271            final int TYPE_RADIO_EDIT_FIELD = 8;
2272            final int TYPE_RESULT_CELL = 9;
2273            final int TYPE_PROGRESSBAR = 10;
2274            final int TYPE_SEARCH_LIST_FIELD = 11;
2275            final int TYPE_ITEM_CARD = 12;
2276            final int TYPE_BUTTON_GRID_FIELD = 13;
2277
2278            protected boolean loading = false;
2279            protected Timer loadingTimer = new Timer();
2280            protected String mTitle;
2281            protected String mNode;
2282            protected CommandPageBinding mBinding = null;
2283            protected IqPacket response = null;
2284            protected Element responseElement = null;
2285            protected List<Field> reported = null;
2286            protected SparseArray<Item> items = new SparseArray<>();
2287            protected XmppConnectionService xmppConnectionService;
2288            protected ActionsAdapter actionsAdapter;
2289            protected GridLayoutManager layoutManager;
2290            protected WebView actionToWebview = null;
2291            protected int fillableFieldCount = 0;
2292
2293            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2294                loading();
2295                mTitle = title;
2296                mNode = node;
2297                this.xmppConnectionService = xmppConnectionService;
2298                if (mPager != null) setupLayoutManager();
2299                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2300                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2301                    @Override
2302                    public void onChanged() {
2303                        if (mBinding == null) return;
2304
2305                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2306                    }
2307
2308                    @Override
2309                    public void onInvalidated() {}
2310                });
2311            }
2312
2313            public String getTitle() {
2314                return mTitle;
2315            }
2316
2317            public void updateWithResponse(IqPacket iq) {
2318                this.loadingTimer.cancel();
2319                this.loadingTimer = new Timer();
2320                this.loading = false;
2321                this.responseElement = null;
2322                this.fillableFieldCount = 0;
2323                this.reported = null;
2324                this.response = iq;
2325                this.items.clear();
2326                this.actionsAdapter.clear();
2327                layoutManager.setSpanCount(1);
2328
2329                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2330                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2331                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status").equals("completed")) {
2332                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2333                    }
2334
2335                    for (Element el : command.getChildren()) {
2336                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2337                            for (Element action : el.getChildren()) {
2338                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2339                                if (action.getName().equals("execute")) continue;
2340
2341                                actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2342                            }
2343                        }
2344                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2345                            Data form = Data.parse(el);
2346                            String title = form.getTitle();
2347                            if (title != null) {
2348                                mTitle = title;
2349                                ConversationPagerAdapter.this.notifyDataSetChanged();
2350                            }
2351
2352                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2353                                this.responseElement = el;
2354                                setupReported(el.findChild("reported", "jabber:x:data"));
2355                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2356                            }
2357
2358                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2359                            if (actionList != null) {
2360                                actionsAdapter.clear();
2361
2362                                for (Option action : actionList.getOptions()) {
2363                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2364                                }
2365                            }
2366
2367                            String fillableFieldType = null;
2368                            String fillableFieldValue = null;
2369                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2370                                if (field.getType() != null && !field.getType().equals("hidden") && !field.getType().equals("fixed") && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2371                                    fillableFieldType = field.getType();
2372                                    fillableFieldValue = field.getValue();
2373                                    fillableFieldCount++;
2374                                }
2375                            }
2376
2377                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2378                                actionsAdapter.clearExceptCancel();
2379                            }
2380                            break;
2381                        }
2382                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2383                            String url = el.findChildContent("url", "jabber:x:oob");
2384                            if (url != null) {
2385                                String scheme = Uri.parse(url).getScheme();
2386                                if (scheme.equals("http") || scheme.equals("https")) {
2387                                    this.responseElement = el;
2388                                    break;
2389                                }
2390                            }
2391                        }
2392                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2393                            this.responseElement = el;
2394                            break;
2395                        }
2396                    }
2397
2398                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2399                        removeSession(this);
2400                        return;
2401                    }
2402
2403                    if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && fillableFieldCount > 1) {
2404                        // No actions have been given, but we are not done?
2405                        // This is probably a spec violation, but we should do *something*
2406                        actionsAdapter.add(Pair.create("execute", "execute"));
2407                    }
2408
2409                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2410                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2411                            actionsAdapter.add(Pair.create("close", "close"));
2412                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2413                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2414                        }
2415                    }
2416                }
2417
2418                if (actionsAdapter.isEmpty()) {
2419                    actionsAdapter.add(Pair.create("close", "close"));
2420                }
2421
2422                notifyDataSetChanged();
2423            }
2424
2425            protected void setupReported(Element el) {
2426                if (el == null) {
2427                    reported = null;
2428                    return;
2429                }
2430
2431                reported = new ArrayList<>();
2432                for (Element fieldEl : el.getChildren()) {
2433                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2434                    reported.add(mkField(fieldEl));
2435                }
2436            }
2437
2438            @Override
2439            public int getItemCount() {
2440                if (loading) return 1;
2441                if (response == null) return 0;
2442                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2443                    int i = 0;
2444                    for (Element el : responseElement.getChildren()) {
2445                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2446                        if (el.getName().equals("title")) continue;
2447                        if (el.getName().equals("field")) {
2448                            String type = el.getAttribute("type");
2449                            if (type != null && type.equals("hidden")) continue;
2450                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2451                        }
2452
2453                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2454                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2455                                if (el.getName().equals("reported")) continue;
2456                                i += 1;
2457                            } else {
2458                                if (reported != null) i += reported.size();
2459                            }
2460                            continue;
2461                        }
2462
2463                        i++;
2464                    }
2465                    return i;
2466                }
2467                return 1;
2468            }
2469
2470            public Item getItem(int position) {
2471                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2472                if (items.get(position) != null) return items.get(position);
2473                if (response == null) return null;
2474
2475                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2476                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2477                        int i = 0;
2478                        for (Element el : responseElement.getChildren()) {
2479                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2480                            if (el.getName().equals("title")) continue;
2481                            if (el.getName().equals("field")) {
2482                                String type = el.getAttribute("type");
2483                                if (type != null && type.equals("hidden")) continue;
2484                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2485                            }
2486
2487                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2488                                Cell cell = null;
2489
2490                                if (reported != null) {
2491                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2492                                        if (el.getName().equals("reported")) continue;
2493                                        if (i == position) {
2494                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2495                                            return items.get(position);
2496                                        }
2497                                    } else {
2498                                        if (reported.size() > position - i) {
2499                                            Field reportedField = reported.get(position - i);
2500                                            Element itemField = null;
2501                                            if (el.getName().equals("item")) {
2502                                                for (Element subel : el.getChildren()) {
2503                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2504                                                       itemField = subel;
2505                                                       break;
2506                                                    }
2507                                                }
2508                                            }
2509                                            cell = new Cell(reportedField, itemField);
2510                                        } else {
2511                                            i += reported.size();
2512                                            continue;
2513                                        }
2514                                    }
2515                                }
2516
2517                                if (cell != null) {
2518                                    items.put(position, cell);
2519                                    return cell;
2520                                }
2521                            }
2522
2523                            if (i < position) {
2524                                i++;
2525                                continue;
2526                            }
2527
2528                            return mkItem(el, position);
2529                        }
2530                    }
2531                }
2532
2533                return mkItem(responseElement == null ? response : responseElement, position);
2534            }
2535
2536            @Override
2537            public int getItemViewType(int position) {
2538                return getItem(position).viewType;
2539            }
2540
2541            @Override
2542            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2543                switch(viewType) {
2544                    case TYPE_ERROR: {
2545                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2546                        return new ErrorViewHolder(binding);
2547                    }
2548                    case TYPE_NOTE: {
2549                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2550                        return new NoteViewHolder(binding);
2551                    }
2552                    case TYPE_WEB: {
2553                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2554                        return new WebViewHolder(binding);
2555                    }
2556                    case TYPE_RESULT_FIELD: {
2557                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2558                        return new ResultFieldViewHolder(binding);
2559                    }
2560                    case TYPE_RESULT_CELL: {
2561                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2562                        return new ResultCellViewHolder(binding);
2563                    }
2564                    case TYPE_ITEM_CARD: {
2565                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2566                        return new ItemCardViewHolder(binding);
2567                    }
2568                    case TYPE_CHECKBOX_FIELD: {
2569                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2570                        return new CheckboxFieldViewHolder(binding);
2571                    }
2572                    case TYPE_SEARCH_LIST_FIELD: {
2573                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2574                        return new SearchListFieldViewHolder(binding);
2575                    }
2576                    case TYPE_RADIO_EDIT_FIELD: {
2577                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2578                        return new RadioEditFieldViewHolder(binding);
2579                    }
2580                    case TYPE_SPINNER_FIELD: {
2581                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2582                        return new SpinnerFieldViewHolder(binding);
2583                    }
2584                    case TYPE_BUTTON_GRID_FIELD: {
2585                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2586                        return new ButtonGridFieldViewHolder(binding);
2587                    }
2588                    case TYPE_TEXT_FIELD: {
2589                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2590                        return new TextFieldViewHolder(binding);
2591                    }
2592                    case TYPE_PROGRESSBAR: {
2593                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2594                        return new ProgressBarViewHolder(binding);
2595                    }
2596                    default:
2597                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2598                }
2599            }
2600
2601            @Override
2602            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2603                viewHolder.bind(getItem(position));
2604            }
2605
2606            public View getView() {
2607                return mBinding.getRoot();
2608            }
2609
2610            public boolean validate() {
2611                int count = getItemCount();
2612                boolean isValid = true;
2613                for (int i = 0; i < count; i++) {
2614                    boolean oneIsValid = getItem(i).validate();
2615                    isValid = isValid && oneIsValid;
2616                }
2617                notifyDataSetChanged();
2618                return isValid;
2619            }
2620
2621            public boolean execute() {
2622                return execute("execute");
2623            }
2624
2625            public boolean execute(int actionPosition) {
2626                return execute(actionsAdapter.getItem(actionPosition).first);
2627            }
2628
2629            public boolean execute(String action) {
2630                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2631
2632                if (response == null) return true;
2633                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2634                if (command == null) return true;
2635                String status = command.getAttribute("status");
2636                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2637
2638                if (actionToWebview != null) {
2639                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2640                    return false;
2641                }
2642
2643                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2644                packet.setTo(response.getFrom());
2645                final Element c = packet.addChild("command", Namespace.COMMANDS);
2646                c.setAttribute("node", mNode);
2647                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2648
2649                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2650                if (!action.equals("cancel") &&
2651                    !action.equals("prev") &&
2652                    responseElement != null &&
2653                    responseElement.getName().equals("x") &&
2654                    responseElement.getNamespace().equals("jabber:x:data") &&
2655                    formType != null && formType.equals("form")) {
2656
2657                    Data form = Data.parse(responseElement);
2658                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2659                    if (actionList != null) {
2660                        actionList.setValue(action);
2661                        c.setAttribute("action", "execute");
2662                    }
2663
2664                    responseElement.setAttribute("type", "submit");
2665                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2666                    if (rsm != null) {
2667                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2668                        max.setContent("1000");
2669                        rsm.addChild(max);
2670                    }
2671
2672                    c.addChild(responseElement);
2673                }
2674
2675                if (c.getAttribute("action") == null) c.setAttribute("action", action);
2676
2677                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2678                    getView().post(() -> {
2679                        updateWithResponse(iq);
2680                    });
2681                });
2682
2683                loading();
2684                return false;
2685            }
2686
2687            protected void loading() {
2688                loadingTimer.schedule(new TimerTask() {
2689                    @Override
2690                    public void run() {
2691                        getView().post(() -> {
2692                            loading = true;
2693                            notifyDataSetChanged();
2694                        });
2695                    }
2696                }, 500);
2697            }
2698
2699            protected GridLayoutManager setupLayoutManager() {
2700                int spanCount = 1;
2701
2702                if (reported != null && mPager != null) {
2703                    float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2704                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2705                    float tableHeaderWidth = reported.stream().reduce(
2706                        0f,
2707                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2708                        (a, b) -> a + b
2709                    );
2710
2711                    spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2712                }
2713
2714                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2715                    items.clear();
2716                    notifyDataSetChanged();
2717                }
2718
2719                layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2720                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2721                    @Override
2722                    public int getSpanSize(int position) {
2723                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2724                        return 1;
2725                    }
2726                });
2727                return layoutManager;
2728            }
2729
2730            public void setBinding(CommandPageBinding b) {
2731                mBinding = b;
2732                // https://stackoverflow.com/a/32350474/8611
2733                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2734                    @Override
2735                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2736                        if(rv.getChildCount() > 0) {
2737                            int[] location = new int[2];
2738                            rv.getLocationOnScreen(location);
2739                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2740                            if (childView instanceof ViewGroup) {
2741                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2742                            }
2743                            if ((childView instanceof ListView && ((ListView) childView).canScrollList(1)) || childView instanceof WebView) {
2744                                int action = e.getAction();
2745                                switch (action) {
2746                                    case MotionEvent.ACTION_DOWN:
2747                                        rv.requestDisallowInterceptTouchEvent(true);
2748                                }
2749                            }
2750                        }
2751
2752                        return false;
2753                    }
2754
2755                    @Override
2756                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2757
2758                    @Override
2759                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2760                });
2761                mBinding.form.setLayoutManager(setupLayoutManager());
2762                mBinding.form.setAdapter(this);
2763                mBinding.actions.setAdapter(actionsAdapter);
2764                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2765                    if (execute(pos)) {
2766                        removeSession(CommandSession.this);
2767                    }
2768                });
2769
2770                actionsAdapter.notifyDataSetChanged();
2771            }
2772
2773            // https://stackoverflow.com/a/36037991/8611
2774            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2775                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2776                    View child = viewGroup.getChildAt(i);
2777                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
2778                        View foundView = findViewAt((ViewGroup) child, x, y);
2779                        if (foundView != null && foundView.isShown()) {
2780                            return foundView;
2781                        }
2782                    } else {
2783                        int[] location = new int[2];
2784                        child.getLocationOnScreen(location);
2785                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2786                        if (rect.contains((int)x, (int)y)) {
2787                            return child;
2788                        }
2789                    }
2790                }
2791
2792                return null;
2793            }
2794        }
2795    }
2796}