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                    mValue = field.getValue();
2063                    binding.textinput.setText(mValue.getContent());
2064                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2065                }
2066
2067                @Override
2068                public void afterTextChanged(Editable s) {
2069                    if (mValue == null) return;
2070
2071                    mValue.setContent(s.toString());
2072                }
2073
2074                @Override
2075                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2076
2077                @Override
2078                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2079            }
2080
2081            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2082                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2083                protected String boundUrl = "";
2084
2085                @Override
2086                public void bind(Item oob) {
2087                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2088                    binding.webview.getSettings().setJavaScriptEnabled(true);
2089                    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");
2090                    binding.webview.getSettings().setDatabaseEnabled(true);
2091                    binding.webview.getSettings().setDomStorageEnabled(true);
2092                    binding.webview.setWebChromeClient(new WebChromeClient() {
2093                        @Override
2094                        public void onProgressChanged(WebView view, int newProgress) {
2095                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2096                            binding.progressbar.setProgress(newProgress);
2097                        }
2098                    });
2099                    binding.webview.setWebViewClient(new WebViewClient() {
2100                        @Override
2101                        public void onPageFinished(WebView view, String url) {
2102                            super.onPageFinished(view, url);
2103                            mTitle = view.getTitle();
2104                            ConversationPagerAdapter.this.notifyDataSetChanged();
2105                        }
2106                    });
2107                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2108                    if (!boundUrl.equals(url)) {
2109                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2110                        binding.webview.loadUrl(url);
2111                        boundUrl = url;
2112                    }
2113                }
2114
2115                class JsObject {
2116                    @JavascriptInterface
2117                    public void execute() { execute("execute"); }
2118
2119                    @JavascriptInterface
2120                    public void execute(String action) {
2121                        getView().post(() -> {
2122                            actionToWebview = null;
2123                            if(CommandSession.this.execute(action)) {
2124                                removeSession(CommandSession.this);
2125                            }
2126                        });
2127                    }
2128
2129                    @JavascriptInterface
2130                    public void preventDefault() {
2131                        actionToWebview = binding.webview;
2132                    }
2133                }
2134            }
2135
2136            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2137                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2138
2139                @Override
2140                public void bind(Item item) { }
2141            }
2142
2143            class Item {
2144                protected Element el;
2145                protected int viewType;
2146                protected String error = null;
2147
2148                Item(Element el, int viewType) {
2149                    this.el = el;
2150                    this.viewType = viewType;
2151                }
2152
2153                public boolean validate() {
2154                    error = null;
2155                    return true;
2156                }
2157            }
2158
2159            class Field extends Item {
2160                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2161
2162                @Override
2163                public boolean validate() {
2164                    if (!super.validate()) return false;
2165                    if (el.findChild("required", "jabber:x:data") == null) return true;
2166                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2167
2168                    error = "this value is required";
2169                    return false;
2170                }
2171
2172                public String getVar() {
2173                    return el.getAttribute("var");
2174                }
2175
2176                public Optional<String> getType() {
2177                    return Optional.fromNullable(el.getAttribute("type"));
2178                }
2179
2180                public Optional<String> getLabel() {
2181                    String label = el.getAttribute("label");
2182                    if (label == null) label = getVar();
2183                    return Optional.fromNullable(label);
2184                }
2185
2186                public Optional<String> getDesc() {
2187                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2188                }
2189
2190                public Element getValue() {
2191                    Element value = el.findChild("value", "jabber:x:data");
2192                    if (value == null) {
2193                        value = el.addChild("value", "jabber:x:data");
2194                    }
2195                    return value;
2196                }
2197
2198                public List<Option> getOptions() {
2199                    return Option.forField(el);
2200                }
2201            }
2202
2203            class Cell extends Item {
2204                protected Field reported;
2205
2206                Cell(Field reported, Element item) {
2207                    super(item, TYPE_RESULT_CELL);
2208                    this.reported = reported;
2209                }
2210            }
2211
2212            protected Field mkField(Element el) {
2213                int viewType = -1;
2214
2215                String formType = responseElement.getAttribute("type");
2216                if (formType != null) {
2217                    String fieldType = el.getAttribute("type");
2218                    if (fieldType == null) fieldType = "text-single";
2219
2220                    if (formType.equals("result") || fieldType.equals("fixed")) {
2221                        viewType = TYPE_RESULT_FIELD;
2222                    } else if (formType.equals("form")) {
2223                        if (fieldType.equals("boolean")) {
2224                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2225                                viewType = TYPE_BUTTON_GRID_FIELD;
2226                            } else {
2227                                viewType = TYPE_CHECKBOX_FIELD;
2228                            }
2229                        } else if (fieldType.equals("list-single")) {
2230                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2231                            if (Option.forField(el).size() > 9) {
2232                                viewType = TYPE_SEARCH_LIST_FIELD;
2233                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2234                                viewType = TYPE_BUTTON_GRID_FIELD;
2235                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2236                                viewType = TYPE_RADIO_EDIT_FIELD;
2237                            } else {
2238                                viewType = TYPE_SPINNER_FIELD;
2239                            }
2240                        } else {
2241                            viewType = TYPE_TEXT_FIELD;
2242                        }
2243                    }
2244
2245                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2246                }
2247
2248                return null;
2249            }
2250
2251            protected Item mkItem(Element el, int pos) {
2252                int viewType = -1;
2253
2254                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2255                    if (el.getName().equals("note")) {
2256                        viewType = TYPE_NOTE;
2257                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2258                        viewType = TYPE_WEB;
2259                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2260                        viewType = TYPE_NOTE;
2261                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2262                        Field field = mkField(el);
2263                        if (field != null) {
2264                            items.put(pos, field);
2265                            return field;
2266                        }
2267                    }
2268                } else if (response != null) {
2269                    viewType = TYPE_ERROR;
2270                }
2271
2272                Item item = new Item(el, viewType);
2273                items.put(pos, item);
2274                return item;
2275            }
2276
2277            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2278                protected Context ctx;
2279
2280                public ActionsAdapter(Context ctx) {
2281                    super(ctx, R.layout.simple_list_item);
2282                    this.ctx = ctx;
2283                }
2284
2285                @Override
2286                public View getView(int position, View convertView, ViewGroup parent) {
2287                    View v = super.getView(position, convertView, parent);
2288                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2289                    tv.setGravity(Gravity.CENTER);
2290                    tv.setText(getItem(position).second);
2291                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2292                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2293                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2294                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2295                    return v;
2296                }
2297
2298                public int getPosition(String s) {
2299                    for(int i = 0; i < getCount(); i++) {
2300                        if (getItem(i).first.equals(s)) return i;
2301                    }
2302                    return -1;
2303                }
2304
2305                public int countExceptCancel() {
2306                    int count = 0;
2307                    for(int i = 0; i < getCount(); i++) {
2308                        if (!getItem(i).first.equals("cancel")) count++;
2309                    }
2310                    return count;
2311                }
2312
2313                public void clearExceptCancel() {
2314                    Pair<String,String> cancelItem = null;
2315                    for(int i = 0; i < getCount(); i++) {
2316                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2317                    }
2318                    clear();
2319                    if (cancelItem != null) add(cancelItem);
2320                }
2321            }
2322
2323            final int TYPE_ERROR = 1;
2324            final int TYPE_NOTE = 2;
2325            final int TYPE_WEB = 3;
2326            final int TYPE_RESULT_FIELD = 4;
2327            final int TYPE_TEXT_FIELD = 5;
2328            final int TYPE_CHECKBOX_FIELD = 6;
2329            final int TYPE_SPINNER_FIELD = 7;
2330            final int TYPE_RADIO_EDIT_FIELD = 8;
2331            final int TYPE_RESULT_CELL = 9;
2332            final int TYPE_PROGRESSBAR = 10;
2333            final int TYPE_SEARCH_LIST_FIELD = 11;
2334            final int TYPE_ITEM_CARD = 12;
2335            final int TYPE_BUTTON_GRID_FIELD = 13;
2336
2337            protected boolean loading = false;
2338            protected Timer loadingTimer = new Timer();
2339            protected String mTitle;
2340            protected String mNode;
2341            protected CommandPageBinding mBinding = null;
2342            protected IqPacket response = null;
2343            protected Element responseElement = null;
2344            protected List<Field> reported = null;
2345            protected SparseArray<Item> items = new SparseArray<>();
2346            protected XmppConnectionService xmppConnectionService;
2347            protected ActionsAdapter actionsAdapter;
2348            protected GridLayoutManager layoutManager;
2349            protected WebView actionToWebview = null;
2350            protected int fillableFieldCount = 0;
2351
2352            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2353                loading();
2354                mTitle = title;
2355                mNode = node;
2356                this.xmppConnectionService = xmppConnectionService;
2357                if (mPager != null) setupLayoutManager();
2358                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2359                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2360                    @Override
2361                    public void onChanged() {
2362                        if (mBinding == null) return;
2363
2364                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2365                    }
2366
2367                    @Override
2368                    public void onInvalidated() {}
2369                });
2370            }
2371
2372            public String getTitle() {
2373                return mTitle;
2374            }
2375
2376            public void updateWithResponse(IqPacket iq) {
2377                this.loadingTimer.cancel();
2378                this.loadingTimer = new Timer();
2379                this.loading = false;
2380                this.responseElement = null;
2381                this.fillableFieldCount = 0;
2382                this.reported = null;
2383                this.response = iq;
2384                this.items.clear();
2385                this.actionsAdapter.clear();
2386                layoutManager.setSpanCount(1);
2387
2388                boolean actionsCleared = false;
2389                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2390                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2391                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2392                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2393                    }
2394
2395                    for (Element el : command.getChildren()) {
2396                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2397                            for (Element action : el.getChildren()) {
2398                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2399                                if (action.getName().equals("execute")) continue;
2400
2401                                actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2402                            }
2403                        }
2404                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2405                            Data form = Data.parse(el);
2406                            String title = form.getTitle();
2407                            if (title != null) {
2408                                mTitle = title;
2409                                ConversationPagerAdapter.this.notifyDataSetChanged();
2410                            }
2411
2412                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2413                                this.responseElement = el;
2414                                setupReported(el.findChild("reported", "jabber:x:data"));
2415                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2416                            }
2417
2418                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2419                            if (actionList != null) {
2420                                actionsAdapter.clear();
2421
2422                                for (Option action : actionList.getOptions()) {
2423                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2424                                }
2425                            }
2426
2427                            String fillableFieldType = null;
2428                            String fillableFieldValue = null;
2429                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2430                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2431                                    fillableFieldType = field.getType();
2432                                    fillableFieldValue = field.getValue();
2433                                    fillableFieldCount++;
2434                                }
2435                            }
2436
2437                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2438                                actionsCleared = true;
2439                                actionsAdapter.clearExceptCancel();
2440                            }
2441                            break;
2442                        }
2443                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2444                            String url = el.findChildContent("url", "jabber:x:oob");
2445                            if (url != null) {
2446                                String scheme = Uri.parse(url).getScheme();
2447                                if (scheme.equals("http") || scheme.equals("https")) {
2448                                    this.responseElement = el;
2449                                    break;
2450                                }
2451                            }
2452                        }
2453                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2454                            this.responseElement = el;
2455                            break;
2456                        }
2457                    }
2458
2459                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2460                        removeSession(this);
2461                        return;
2462                    }
2463
2464                    if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2465                        // No actions have been given, but we are not done?
2466                        // This is probably a spec violation, but we should do *something*
2467                        actionsAdapter.add(Pair.create("execute", "execute"));
2468                    }
2469
2470                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2471                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2472                            actionsAdapter.add(Pair.create("close", "close"));
2473                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2474                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2475                        }
2476                    }
2477                }
2478
2479                if (actionsAdapter.isEmpty()) {
2480                    actionsAdapter.add(Pair.create("close", "close"));
2481                }
2482
2483                notifyDataSetChanged();
2484            }
2485
2486            protected void setupReported(Element el) {
2487                if (el == null) {
2488                    reported = null;
2489                    return;
2490                }
2491
2492                reported = new ArrayList<>();
2493                for (Element fieldEl : el.getChildren()) {
2494                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2495                    reported.add(mkField(fieldEl));
2496                }
2497            }
2498
2499            @Override
2500            public int getItemCount() {
2501                if (loading) return 1;
2502                if (response == null) return 0;
2503                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2504                    int i = 0;
2505                    for (Element el : responseElement.getChildren()) {
2506                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2507                        if (el.getName().equals("title")) continue;
2508                        if (el.getName().equals("field")) {
2509                            String type = el.getAttribute("type");
2510                            if (type != null && type.equals("hidden")) continue;
2511                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2512                        }
2513
2514                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2515                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2516                                if (el.getName().equals("reported")) continue;
2517                                i += 1;
2518                            } else {
2519                                if (reported != null) i += reported.size();
2520                            }
2521                            continue;
2522                        }
2523
2524                        i++;
2525                    }
2526                    return i;
2527                }
2528                return 1;
2529            }
2530
2531            public Item getItem(int position) {
2532                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2533                if (items.get(position) != null) return items.get(position);
2534                if (response == null) return null;
2535
2536                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2537                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2538                        int i = 0;
2539                        for (Element el : responseElement.getChildren()) {
2540                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2541                            if (el.getName().equals("title")) continue;
2542                            if (el.getName().equals("field")) {
2543                                String type = el.getAttribute("type");
2544                                if (type != null && type.equals("hidden")) continue;
2545                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2546                            }
2547
2548                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2549                                Cell cell = null;
2550
2551                                if (reported != null) {
2552                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2553                                        if (el.getName().equals("reported")) continue;
2554                                        if (i == position) {
2555                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2556                                            return items.get(position);
2557                                        }
2558                                    } else {
2559                                        if (reported.size() > position - i) {
2560                                            Field reportedField = reported.get(position - i);
2561                                            Element itemField = null;
2562                                            if (el.getName().equals("item")) {
2563                                                for (Element subel : el.getChildren()) {
2564                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2565                                                       itemField = subel;
2566                                                       break;
2567                                                    }
2568                                                }
2569                                            }
2570                                            cell = new Cell(reportedField, itemField);
2571                                        } else {
2572                                            i += reported.size();
2573                                            continue;
2574                                        }
2575                                    }
2576                                }
2577
2578                                if (cell != null) {
2579                                    items.put(position, cell);
2580                                    return cell;
2581                                }
2582                            }
2583
2584                            if (i < position) {
2585                                i++;
2586                                continue;
2587                            }
2588
2589                            return mkItem(el, position);
2590                        }
2591                    }
2592                }
2593
2594                return mkItem(responseElement == null ? response : responseElement, position);
2595            }
2596
2597            @Override
2598            public int getItemViewType(int position) {
2599                return getItem(position).viewType;
2600            }
2601
2602            @Override
2603            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2604                switch(viewType) {
2605                    case TYPE_ERROR: {
2606                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2607                        return new ErrorViewHolder(binding);
2608                    }
2609                    case TYPE_NOTE: {
2610                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2611                        return new NoteViewHolder(binding);
2612                    }
2613                    case TYPE_WEB: {
2614                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2615                        return new WebViewHolder(binding);
2616                    }
2617                    case TYPE_RESULT_FIELD: {
2618                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2619                        return new ResultFieldViewHolder(binding);
2620                    }
2621                    case TYPE_RESULT_CELL: {
2622                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2623                        return new ResultCellViewHolder(binding);
2624                    }
2625                    case TYPE_ITEM_CARD: {
2626                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2627                        return new ItemCardViewHolder(binding);
2628                    }
2629                    case TYPE_CHECKBOX_FIELD: {
2630                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2631                        return new CheckboxFieldViewHolder(binding);
2632                    }
2633                    case TYPE_SEARCH_LIST_FIELD: {
2634                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2635                        return new SearchListFieldViewHolder(binding);
2636                    }
2637                    case TYPE_RADIO_EDIT_FIELD: {
2638                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2639                        return new RadioEditFieldViewHolder(binding);
2640                    }
2641                    case TYPE_SPINNER_FIELD: {
2642                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2643                        return new SpinnerFieldViewHolder(binding);
2644                    }
2645                    case TYPE_BUTTON_GRID_FIELD: {
2646                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2647                        return new ButtonGridFieldViewHolder(binding);
2648                    }
2649                    case TYPE_TEXT_FIELD: {
2650                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2651                        return new TextFieldViewHolder(binding);
2652                    }
2653                    case TYPE_PROGRESSBAR: {
2654                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2655                        return new ProgressBarViewHolder(binding);
2656                    }
2657                    default:
2658                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2659                }
2660            }
2661
2662            @Override
2663            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2664                viewHolder.bind(getItem(position));
2665            }
2666
2667            public View getView() {
2668                return mBinding.getRoot();
2669            }
2670
2671            public boolean validate() {
2672                int count = getItemCount();
2673                boolean isValid = true;
2674                for (int i = 0; i < count; i++) {
2675                    boolean oneIsValid = getItem(i).validate();
2676                    isValid = isValid && oneIsValid;
2677                }
2678                notifyDataSetChanged();
2679                return isValid;
2680            }
2681
2682            public boolean execute() {
2683                return execute("execute");
2684            }
2685
2686            public boolean execute(int actionPosition) {
2687                return execute(actionsAdapter.getItem(actionPosition).first);
2688            }
2689
2690            public boolean execute(String action) {
2691                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2692
2693                if (response == null) return true;
2694                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2695                if (command == null) return true;
2696                String status = command.getAttribute("status");
2697                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2698
2699                if (actionToWebview != null && !action.equals("cancel")) {
2700                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2701                    return false;
2702                }
2703
2704                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2705                packet.setTo(response.getFrom());
2706                final Element c = packet.addChild("command", Namespace.COMMANDS);
2707                c.setAttribute("node", mNode);
2708                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2709
2710                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2711                if (!action.equals("cancel") &&
2712                    !action.equals("prev") &&
2713                    responseElement != null &&
2714                    responseElement.getName().equals("x") &&
2715                    responseElement.getNamespace().equals("jabber:x:data") &&
2716                    formType != null && formType.equals("form")) {
2717
2718                    Data form = Data.parse(responseElement);
2719                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2720                    if (actionList != null) {
2721                        actionList.setValue(action);
2722                        c.setAttribute("action", "execute");
2723                    }
2724
2725                    responseElement.setAttribute("type", "submit");
2726                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2727                    if (rsm != null) {
2728                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2729                        max.setContent("1000");
2730                        rsm.addChild(max);
2731                    }
2732
2733                    c.addChild(responseElement);
2734                }
2735
2736                if (c.getAttribute("action") == null) c.setAttribute("action", action);
2737
2738                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2739                    getView().post(() -> {
2740                        updateWithResponse(iq);
2741                    });
2742                });
2743
2744                loading();
2745                return false;
2746            }
2747
2748            protected void loading() {
2749                loadingTimer.schedule(new TimerTask() {
2750                    @Override
2751                    public void run() {
2752                        getView().post(() -> {
2753                            loading = true;
2754                            notifyDataSetChanged();
2755                        });
2756                    }
2757                }, 500);
2758            }
2759
2760            protected GridLayoutManager setupLayoutManager() {
2761                int spanCount = 1;
2762
2763                if (reported != null && mPager != null) {
2764                    float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2765                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2766                    float tableHeaderWidth = reported.stream().reduce(
2767                        0f,
2768                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2769                        (a, b) -> a + b
2770                    );
2771
2772                    spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2773                }
2774
2775                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2776                    items.clear();
2777                    notifyDataSetChanged();
2778                }
2779
2780                layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2781                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2782                    @Override
2783                    public int getSpanSize(int position) {
2784                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2785                        return 1;
2786                    }
2787                });
2788                return layoutManager;
2789            }
2790
2791            public void setBinding(CommandPageBinding b) {
2792                mBinding = b;
2793                // https://stackoverflow.com/a/32350474/8611
2794                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2795                    @Override
2796                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2797                        if(rv.getChildCount() > 0) {
2798                            int[] location = new int[2];
2799                            rv.getLocationOnScreen(location);
2800                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2801                            if (childView instanceof ViewGroup) {
2802                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2803                            }
2804                            int action = e.getAction();
2805                            switch (action) {
2806                                case MotionEvent.ACTION_DOWN:
2807                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2808                                        rv.requestDisallowInterceptTouchEvent(true);
2809                                    }
2810                                case MotionEvent.ACTION_UP:
2811                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2812                                        rv.requestDisallowInterceptTouchEvent(true);
2813                                    }
2814                            }
2815                        }
2816
2817                        return false;
2818                    }
2819
2820                    @Override
2821                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2822
2823                    @Override
2824                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2825                });
2826                mBinding.form.setLayoutManager(setupLayoutManager());
2827                mBinding.form.setAdapter(this);
2828                mBinding.actions.setAdapter(actionsAdapter);
2829                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2830                    if (execute(pos)) {
2831                        removeSession(CommandSession.this);
2832                    }
2833                });
2834
2835                actionsAdapter.notifyDataSetChanged();
2836            }
2837
2838            // https://stackoverflow.com/a/36037991/8611
2839            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2840                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2841                    View child = viewGroup.getChildAt(i);
2842                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2843                        View foundView = findViewAt((ViewGroup) child, x, y);
2844                        if (foundView != null && foundView.isShown()) {
2845                            return foundView;
2846                        }
2847                    } else {
2848                        int[] location = new int[2];
2849                        child.getLocationOnScreen(location);
2850                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2851                        if (rect.contains((int)x, (int)y)) {
2852                            return child;
2853                        }
2854                    }
2855                }
2856
2857                return null;
2858            }
2859        }
2860    }
2861}