Conversation.java

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