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
1394            if (command.getAttribute("node").equals("jabber:iq:register") && packet.getTo().asBareJid().equals(Jid.of("cheogram.com"))) {
1395                new com.cheogram.android.CheogramLicenseChecker(mPager.getContext(), (signedData, signature) -> {
1396                    if (signedData != null && signature != null) {
1397                        c.addChild("license", "https://ns.cheogram.com/google-play").setContent(signedData);
1398                        c.addChild("licenseSignature", "https://ns.cheogram.com/google-play").setContent(signature);
1399                    }
1400
1401                    xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1402                        session.updateWithResponse(iq);
1403                    });
1404                }).checkLicense();
1405            } else {
1406                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
1407                    session.updateWithResponse(iq);
1408                });
1409            }
1410
1411            sessions.add(session);
1412            notifyDataSetChanged();
1413            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
1414        }
1415
1416        public void removeSession(CommandSession session) {
1417            sessions.remove(session);
1418            notifyDataSetChanged();
1419        }
1420
1421        @NonNull
1422        @Override
1423        public Object instantiateItem(@NonNull ViewGroup container, int position) {
1424            if (position == 0) {
1425                if (page1.getParent() == null) container.addView(page1);
1426                return page1;
1427            }
1428            if (position == 1) {
1429                if (page2.getParent() == null) container.addView(page2);
1430                return page2;
1431            }
1432
1433            CommandSession session = sessions.get(position-2);
1434            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
1435            container.addView(binding.getRoot());
1436            session.setBinding(binding);
1437            return session;
1438        }
1439
1440        @Override
1441        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
1442            if (position < 2) return;
1443
1444            container.removeView(((CommandSession) o).getView());
1445        }
1446
1447        @Override
1448        public int getItemPosition(Object o) {
1449            if (mPager != null) {
1450                if (o == page1) return PagerAdapter.POSITION_UNCHANGED;
1451                if (o == page2) return PagerAdapter.POSITION_UNCHANGED;
1452            }
1453
1454            int pos = sessions == null ? -1 : sessions.indexOf(o);
1455            if (pos < 0) return PagerAdapter.POSITION_NONE;
1456            return pos + 2;
1457        }
1458
1459        @Override
1460        public int getCount() {
1461            if (sessions == null) return 1;
1462
1463            int count = 2 + sessions.size();
1464            if (mTabs == null) return count;
1465
1466            if (count > 2) {
1467                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
1468            } else {
1469                mTabs.setTabMode(TabLayout.MODE_FIXED);
1470            }
1471            return count;
1472        }
1473
1474        @Override
1475        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
1476            if (view == o) return true;
1477
1478            if (o instanceof CommandSession) {
1479                return ((CommandSession) o).getView() == view;
1480            }
1481
1482            return false;
1483        }
1484
1485        @Nullable
1486        @Override
1487        public CharSequence getPageTitle(int position) {
1488            switch (position) {
1489                case 0:
1490                    return "Conversation";
1491                case 1:
1492                    return "Commands";
1493                default:
1494                    CommandSession session = sessions.get(position-2);
1495                    if (session == null) return super.getPageTitle(position);
1496                    return session.getTitle();
1497            }
1498        }
1499
1500        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
1501            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
1502                protected T binding;
1503
1504                public ViewHolder(T binding) {
1505                    super(binding.getRoot());
1506                    this.binding = binding;
1507                }
1508
1509                abstract public void bind(Item el);
1510
1511                protected void setTextOrHide(TextView v, Optional<String> s) {
1512                    if (s == null || !s.isPresent()) {
1513                        v.setVisibility(View.GONE);
1514                    } else {
1515                        v.setVisibility(View.VISIBLE);
1516                        v.setText(s.get());
1517                    }
1518                }
1519
1520                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
1521                    int flags = 0;
1522                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
1523                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1524
1525                    String type = field.getAttribute("type");
1526                    if (type != null) {
1527                        if (type.equals("text-multi") || type.equals("jid-multi")) {
1528                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
1529                        }
1530
1531                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
1532
1533                        if (type.equals("jid-single") || type.equals("jid-multi")) {
1534                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1535                        }
1536
1537                        if (type.equals("text-private")) {
1538                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
1539                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
1540                        }
1541                    }
1542
1543                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1544                    if (validate == null) return;
1545                    String datatype = validate.getAttribute("datatype");
1546                    if (datatype == null) return;
1547
1548                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
1549                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
1550                    }
1551
1552                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
1553                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
1554                    }
1555
1556                    if (datatype.equals("xs:date")) {
1557                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
1558                    }
1559
1560                    if (datatype.equals("xs:dateTime")) {
1561                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
1562                    }
1563
1564                    if (datatype.equals("xs:time")) {
1565                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
1566                    }
1567
1568                    if (datatype.equals("xs:anyURI")) {
1569                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
1570                    }
1571
1572                    if (datatype.equals("html:tel")) {
1573                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
1574                    }
1575
1576                    if (datatype.equals("html:email")) {
1577                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
1578                    }
1579                }
1580            }
1581
1582            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
1583                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }
1584
1585                @Override
1586                public void bind(Item iq) {
1587                    binding.errorIcon.setVisibility(View.VISIBLE);
1588
1589                    Element error = iq.el.findChild("error");
1590                    if (error == null) return;
1591                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
1592                    if (text == null || text.equals("")) {
1593                        text = error.getChildren().get(0).getName();
1594                    }
1595                    binding.message.setText(text);
1596                }
1597            }
1598
1599            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
1600                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }
1601
1602                @Override
1603                public void bind(Item note) {
1604                    binding.message.setText(note.el.getContent());
1605
1606                    String type = note.el.getAttribute("type");
1607                    if (type != null && type.equals("error")) {
1608                        binding.errorIcon.setVisibility(View.VISIBLE);
1609                    }
1610                }
1611            }
1612
1613            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
1614                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }
1615
1616                @Override
1617                public void bind(Item item) {
1618                    Field field = (Field) item;
1619                    setTextOrHide(binding.label, field.getLabel());
1620                    setTextOrHide(binding.desc, field.getDesc());
1621
1622                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
1623                    for (Element el : field.el.getChildren()) {
1624                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
1625                            values.add(el.getContent());
1626                        }
1627                    }
1628                    binding.values.setAdapter(values);
1629
1630                    if (field.getType().equals(Optional.of("jid-single")) || field.getType().equals(Optional.of("jid-multi"))) {
1631                        binding.values.setOnItemClickListener((arg0, arg1, pos, id) -> {
1632                            new FixedURLSpan("xmpp:" + Jid.ofEscaped(values.getItem(pos)).toEscapedString()).onClick(binding.values);
1633                        });
1634                    }
1635
1636                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
1637                        if (ShareUtil.copyTextToClipboard(binding.getRoot().getContext(), values.getItem(pos), R.string.message)) {
1638                            Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
1639                        }
1640                        return true;
1641                    });
1642                }
1643            }
1644
1645            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
1646                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }
1647
1648                @Override
1649                public void bind(Item item) {
1650                    Cell cell = (Cell) item;
1651
1652                    if (cell.el == null) {
1653                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
1654                        setTextOrHide(binding.text, cell.reported.getLabel());
1655                    } else {
1656                        String value = cell.el.findChildContent("value", "jabber:x:data");
1657                        SpannableStringBuilder text = new SpannableStringBuilder(value == null ? "" : value);
1658                        if (cell.reported.getType().equals(Optional.of("jid-single"))) {
1659                            text.setSpan(new FixedURLSpan("xmpp:" + Jid.ofEscaped(text.toString()).toEscapedString()), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1660                        }
1661
1662                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
1663                        binding.text.setText(text);
1664
1665                        BetterLinkMovementMethod method = BetterLinkMovementMethod.newInstance();
1666                        method.setOnLinkLongClickListener((tv, url) -> {
1667                            tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
1668                            ShareUtil.copyLinkToClipboard(binding.getRoot().getContext(), url);
1669                            return true;
1670                        });
1671                        binding.text.setMovementMethod(method);
1672                    }
1673                }
1674            }
1675
1676            class ItemCardViewHolder extends ViewHolder<CommandItemCardBinding> {
1677                public ItemCardViewHolder(CommandItemCardBinding binding) { super(binding); }
1678
1679                @Override
1680                public void bind(Item item) {
1681                    binding.fields.removeAllViews();
1682
1683                    for (Field field : reported) {
1684                        CommandResultFieldBinding row = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.command_result_field, binding.fields, false);
1685                        GridLayout.LayoutParams param = new GridLayout.LayoutParams();
1686                        param.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f);
1687                        param.width = 0;
1688                        row.getRoot().setLayoutParams(param);
1689                        binding.fields.addView(row.getRoot());
1690                        for (Element el : item.el.getChildren()) {
1691                            if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data") && el.getAttribute("var") != null && el.getAttribute("var").equals(field.getVar())) {
1692                                for (String label : field.getLabel().asSet()) {
1693                                    el.setAttribute("label", label);
1694                                }
1695                                for (String desc : field.getDesc().asSet()) {
1696                                    el.setAttribute("desc", desc);
1697                                }
1698                                for (String type : field.getType().asSet()) {
1699                                    el.setAttribute("type", type);
1700                                }
1701                                Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1702                                if (validate != null) el.addChild(validate);
1703                                new ResultFieldViewHolder(row).bind(new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), -1));
1704                            }
1705                        }
1706                    }
1707                }
1708            }
1709
1710            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
1711                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
1712                    super(binding);
1713                    binding.row.setOnClickListener((v) -> {
1714                        binding.checkbox.toggle();
1715                    });
1716                    binding.checkbox.setOnCheckedChangeListener(this);
1717                }
1718                protected Element mValue = null;
1719
1720                @Override
1721                public void bind(Item item) {
1722                    Field field = (Field) item;
1723                    binding.label.setText(field.getLabel().or(""));
1724                    setTextOrHide(binding.desc, field.getDesc());
1725                    mValue = field.getValue();
1726                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
1727                }
1728
1729                @Override
1730                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
1731                    if (mValue == null) return;
1732
1733                    mValue.setContent(isChecked ? "true" : "false");
1734                }
1735            }
1736
1737            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
1738                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
1739                    super(binding);
1740                    binding.search.addTextChangedListener(this);
1741                }
1742                protected Element mValue = null;
1743                List<Option> options = new ArrayList<>();
1744                protected ArrayAdapter<Option> adapter;
1745                protected boolean open;
1746
1747                @Override
1748                public void bind(Item item) {
1749                    Field field = (Field) item;
1750                    setTextOrHide(binding.label, field.getLabel());
1751                    setTextOrHide(binding.desc, field.getDesc());
1752
1753                    if (field.error != null) {
1754                        binding.desc.setVisibility(View.VISIBLE);
1755                        binding.desc.setText(field.error);
1756                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1757                    } else {
1758                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1759                    }
1760
1761                    mValue = field.getValue();
1762
1763                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1764                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
1765                    setupInputType(field.el, binding.search, null);
1766
1767                    options = field.getOptions();
1768                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
1769                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
1770                        if (open) binding.search.setText(mValue.getContent());
1771                    });
1772                    search("");
1773                }
1774
1775                @Override
1776                public void afterTextChanged(Editable s) {
1777                    if (open) mValue.setContent(s.toString());
1778                    search(s.toString());
1779                }
1780
1781                @Override
1782                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1783
1784                @Override
1785                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1786
1787                protected void search(String s) {
1788                    List<Option> filteredOptions;
1789                    final String q = s.replaceAll("\\W", "").toLowerCase();
1790                    if (q == null || q.equals("")) {
1791                        filteredOptions = options;
1792                    } else {
1793                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
1794                    }
1795                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
1796                    binding.list.setAdapter(adapter);
1797
1798                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
1799                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
1800                }
1801            }
1802
1803            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
1804                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
1805                    super(binding);
1806                    binding.open.addTextChangedListener(this);
1807                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
1808                        @Override
1809                        public View getView(int position, View convertView, ViewGroup parent) {
1810                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
1811                            v.setId(position);
1812                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
1813                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
1814                            return v;
1815                        }
1816                    };
1817                }
1818                protected Element mValue = null;
1819                protected ArrayAdapter<Option> options;
1820
1821                @Override
1822                public void bind(Item item) {
1823                    Field field = (Field) item;
1824                    setTextOrHide(binding.label, field.getLabel());
1825                    setTextOrHide(binding.desc, field.getDesc());
1826
1827                    if (field.error != null) {
1828                        binding.desc.setVisibility(View.VISIBLE);
1829                        binding.desc.setText(field.error);
1830                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1831                    } else {
1832                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1833                    }
1834
1835                    mValue = field.getValue();
1836
1837                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1838                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1839                    binding.open.setText(mValue.getContent());
1840                    setupInputType(field.el, binding.open, null);
1841
1842                    options.clear();
1843                    List<Option> theOptions = field.getOptions();
1844                    options.addAll(theOptions);
1845
1846                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
1847                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
1848                    float maxColumnWidth = theOptions.stream().map((x) ->
1849                        StaticLayout.getDesiredWidth(x.toString(), paint)
1850                    ).max(Float::compare).orElse(new Float(0.0));
1851                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
1852                        binding.radios.setNumColumns(theOptions.size());
1853                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
1854                        binding.radios.setNumColumns(theOptions.size() / 2);
1855                    } else {
1856                        binding.radios.setNumColumns(1);
1857                    }
1858                    binding.radios.setAdapter(options);
1859                }
1860
1861                @Override
1862                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
1863                    if (mValue == null) return;
1864
1865                    if (isChecked) {
1866                        mValue.setContent(options.getItem(radio.getId()).getValue());
1867                        binding.open.setText(mValue.getContent());
1868                    }
1869                    options.notifyDataSetChanged();
1870                }
1871
1872                @Override
1873                public void afterTextChanged(Editable s) {
1874                    if (mValue == null) return;
1875
1876                    mValue.setContent(s.toString());
1877                    options.notifyDataSetChanged();
1878                }
1879
1880                @Override
1881                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
1882
1883                @Override
1884                public void onTextChanged(CharSequence s, int start, int count, int after) { }
1885            }
1886
1887            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
1888                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
1889                    super(binding);
1890                    binding.spinner.setOnItemSelectedListener(this);
1891                }
1892                protected Element mValue = null;
1893
1894                @Override
1895                public void bind(Item item) {
1896                    Field field = (Field) item;
1897                    setTextOrHide(binding.label, field.getLabel());
1898                    binding.spinner.setPrompt(field.getLabel().or(""));
1899                    setTextOrHide(binding.desc, field.getDesc());
1900
1901                    mValue = field.getValue();
1902
1903                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
1904                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
1905                    options.addAll(field.getOptions());
1906
1907                    binding.spinner.setAdapter(options);
1908                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
1909                }
1910
1911                @Override
1912                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
1913                    Option o = (Option) parent.getItemAtPosition(pos);
1914                    if (mValue == null) return;
1915
1916                    mValue.setContent(o == null ? "" : o.getValue());
1917                }
1918
1919                @Override
1920                public void onNothingSelected(AdapterView<?> parent) {
1921                    mValue.setContent("");
1922                }
1923            }
1924
1925            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
1926                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
1927                    super(binding);
1928                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
1929                        @Override
1930                        public View getView(int position, View convertView, ViewGroup parent) {
1931                            Button v = (Button) super.getView(position, convertView, parent);
1932                            v.setOnClickListener((view) -> {
1933                                loading = true;
1934                                mValue.setContent(getItem(position).getValue());
1935                                execute();
1936                            });
1937
1938                            final SVG icon = getItem(position).getIcon();
1939                            if (icon != null) {
1940                                 v.post(() -> {
1941                                     if (v.getHeight() == 0) return;
1942                                     icon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
1943                                     Bitmap bitmap = Bitmap.createBitmap(v.getHeight(), v.getHeight(), Bitmap.Config.ARGB_8888);
1944                                     Canvas bmcanvas = new Canvas(bitmap);
1945                                     icon.renderToCanvas(bmcanvas);
1946                                     v.setCompoundDrawablesRelativeWithIntrinsicBounds(new BitmapDrawable(bitmap), null, null, null);
1947                                 });
1948                            }
1949
1950                            return v;
1951                        }
1952                    };
1953                }
1954                protected Element mValue = null;
1955                protected ArrayAdapter<Option> options;
1956                protected Option defaultOption = null;
1957
1958                @Override
1959                public void bind(Item item) {
1960                    Field field = (Field) item;
1961                    setTextOrHide(binding.label, field.getLabel());
1962                    setTextOrHide(binding.desc, field.getDesc());
1963
1964                    if (field.error != null) {
1965                        binding.desc.setVisibility(View.VISIBLE);
1966                        binding.desc.setText(field.error);
1967                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
1968                    } else {
1969                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
1970                    }
1971
1972                    mValue = field.getValue();
1973
1974                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
1975                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
1976                    binding.openButton.setOnClickListener((view) -> {
1977                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
1978                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
1979                        builder.setPositiveButton(R.string.action_execute, null);
1980                        if (field.getDesc().isPresent()) {
1981                            dialogBinding.inputLayout.setHint(field.getDesc().get());
1982                        }
1983                        dialogBinding.inputEditText.requestFocus();
1984                        dialogBinding.inputEditText.getText().append(mValue.getContent());
1985                        builder.setView(dialogBinding.getRoot());
1986                        builder.setNegativeButton(R.string.cancel, null);
1987                        final AlertDialog dialog = builder.create();
1988                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
1989                        dialog.show();
1990                        View.OnClickListener clickListener = v -> {
1991                            loading = true;
1992                            String value = dialogBinding.inputEditText.getText().toString();
1993                            mValue.setContent(value);
1994                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
1995                            dialog.dismiss();
1996                            execute();
1997                        };
1998                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
1999                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
2000                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2001                            dialog.dismiss();
2002                        }));
2003                        dialog.setCanceledOnTouchOutside(false);
2004                        dialog.setOnDismissListener(dialog1 -> {
2005                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
2006                        });
2007                    });
2008
2009                    options.clear();
2010                    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();
2011
2012                    defaultOption = null;
2013                    for (Option option : theOptions) {
2014                        if (option.getValue().equals(mValue.getContent())) {
2015                            defaultOption = option;
2016                            break;
2017                        }
2018                    }
2019                    if (defaultOption == null && !mValue.getContent().equals("")) {
2020                        // Synthesize default option for custom value
2021                        defaultOption = new Option(mValue.getContent(), mValue.getContent());
2022                    }
2023                    if (defaultOption == null) {
2024                        binding.defaultButton.setVisibility(View.GONE);
2025                    } else {
2026                        theOptions.remove(defaultOption);
2027                        binding.defaultButton.setVisibility(View.VISIBLE);
2028
2029                        final SVG defaultIcon = defaultOption.getIcon();
2030                        if (defaultIcon != null) {
2031                             defaultIcon.setDocumentPreserveAspectRatio(com.caverock.androidsvg.PreserveAspectRatio.TOP);
2032                             DisplayMetrics display = mPager.getContext().getResources().getDisplayMetrics();
2033                             Bitmap bitmap = Bitmap.createBitmap((int)(display.heightPixels*display.density/4), (int)(display.heightPixels*display.density/4), Bitmap.Config.ARGB_8888);
2034                             bitmap.setDensity(display.densityDpi);
2035                             Canvas bmcanvas = new Canvas(bitmap);
2036                             defaultIcon.renderToCanvas(bmcanvas);
2037                             binding.defaultButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, new BitmapDrawable(bitmap), null, null);
2038                        }
2039
2040                        binding.defaultButton.setText(defaultOption.toString());
2041                        binding.defaultButton.setOnClickListener((view) -> {
2042                            loading = true;
2043                            mValue.setContent(defaultOption.getValue());
2044                            execute();
2045                        });
2046                    }
2047
2048                    options.addAll(theOptions);
2049                    binding.buttons.setAdapter(options);
2050                }
2051            }
2052
2053            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
2054                public TextFieldViewHolder(CommandTextFieldBinding binding) {
2055                    super(binding);
2056                    binding.textinput.addTextChangedListener(this);
2057                }
2058                protected Element mValue = null;
2059
2060                @Override
2061                public void bind(Item item) {
2062                    Field field = (Field) item;
2063                    binding.textinputLayout.setHint(field.getLabel().or(""));
2064
2065                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
2066                    for (String desc : field.getDesc().asSet()) {
2067                        binding.textinputLayout.setHelperText(desc);
2068                    }
2069
2070                    binding.textinputLayout.setErrorEnabled(field.error != null);
2071                    if (field.error != null) binding.textinputLayout.setError(field.error);
2072
2073                    binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY);
2074                    String suffixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/suffix-label");
2075                    if (suffixLabel != null) {
2076                        binding.textinputLayout.setSuffixText(suffixLabel);
2077                        binding.textinput.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_END);
2078                    }
2079
2080                    String prefixLabel = field.el.findChildContent("x", "https://ns.cheogram.com/prefix-label");
2081                    if (prefixLabel != null) {
2082                        binding.textinputLayout.setPrefixText(prefixLabel);
2083                    }
2084
2085                    mValue = field.getValue();
2086                    binding.textinput.setText(mValue.getContent());
2087                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
2088                }
2089
2090                @Override
2091                public void afterTextChanged(Editable s) {
2092                    if (mValue == null) return;
2093
2094                    mValue.setContent(s.toString());
2095                }
2096
2097                @Override
2098                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
2099
2100                @Override
2101                public void onTextChanged(CharSequence s, int start, int count, int after) { }
2102            }
2103
2104            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
2105                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }
2106                protected String boundUrl = "";
2107
2108                @Override
2109                public void bind(Item oob) {
2110                    setTextOrHide(binding.desc, Optional.fromNullable(oob.el.findChildContent("desc", "jabber:x:oob")));
2111                    binding.webview.getSettings().setJavaScriptEnabled(true);
2112                    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");
2113                    binding.webview.getSettings().setDatabaseEnabled(true);
2114                    binding.webview.getSettings().setDomStorageEnabled(true);
2115                    binding.webview.setWebChromeClient(new WebChromeClient() {
2116                        @Override
2117                        public void onProgressChanged(WebView view, int newProgress) {
2118                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
2119                            binding.progressbar.setProgress(newProgress);
2120                        }
2121                    });
2122                    binding.webview.setWebViewClient(new WebViewClient() {
2123                        @Override
2124                        public void onPageFinished(WebView view, String url) {
2125                            super.onPageFinished(view, url);
2126                            mTitle = view.getTitle();
2127                            ConversationPagerAdapter.this.notifyDataSetChanged();
2128                        }
2129                    });
2130                    final String url = oob.el.findChildContent("url", "jabber:x:oob");
2131                    if (!boundUrl.equals(url)) {
2132                        binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
2133                        binding.webview.loadUrl(url);
2134                        boundUrl = url;
2135                    }
2136                }
2137
2138                class JsObject {
2139                    @JavascriptInterface
2140                    public void execute() { execute("execute"); }
2141
2142                    @JavascriptInterface
2143                    public void execute(String action) {
2144                        getView().post(() -> {
2145                            actionToWebview = null;
2146                            if(CommandSession.this.execute(action)) {
2147                                removeSession(CommandSession.this);
2148                            }
2149                        });
2150                    }
2151
2152                    @JavascriptInterface
2153                    public void preventDefault() {
2154                        actionToWebview = binding.webview;
2155                    }
2156                }
2157            }
2158
2159            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
2160                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }
2161
2162                @Override
2163                public void bind(Item item) { }
2164            }
2165
2166            class Item {
2167                protected Element el;
2168                protected int viewType;
2169                protected String error = null;
2170
2171                Item(Element el, int viewType) {
2172                    this.el = el;
2173                    this.viewType = viewType;
2174                }
2175
2176                public boolean validate() {
2177                    error = null;
2178                    return true;
2179                }
2180            }
2181
2182            class Field extends Item {
2183                Field(eu.siacs.conversations.xmpp.forms.Field el, int viewType) { super(el, viewType); }
2184
2185                @Override
2186                public boolean validate() {
2187                    if (!super.validate()) return false;
2188                    if (el.findChild("required", "jabber:x:data") == null) return true;
2189                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;
2190
2191                    error = "this value is required";
2192                    return false;
2193                }
2194
2195                public String getVar() {
2196                    return el.getAttribute("var");
2197                }
2198
2199                public Optional<String> getType() {
2200                    return Optional.fromNullable(el.getAttribute("type"));
2201                }
2202
2203                public Optional<String> getLabel() {
2204                    String label = el.getAttribute("label");
2205                    if (label == null) label = getVar();
2206                    return Optional.fromNullable(label);
2207                }
2208
2209                public Optional<String> getDesc() {
2210                    return Optional.fromNullable(el.findChildContent("desc", "jabber:x:data"));
2211                }
2212
2213                public Element getValue() {
2214                    Element value = el.findChild("value", "jabber:x:data");
2215                    if (value == null) {
2216                        value = el.addChild("value", "jabber:x:data");
2217                    }
2218                    return value;
2219                }
2220
2221                public List<Option> getOptions() {
2222                    return Option.forField(el);
2223                }
2224            }
2225
2226            class Cell extends Item {
2227                protected Field reported;
2228
2229                Cell(Field reported, Element item) {
2230                    super(item, TYPE_RESULT_CELL);
2231                    this.reported = reported;
2232                }
2233            }
2234
2235            protected Field mkField(Element el) {
2236                int viewType = -1;
2237
2238                String formType = responseElement.getAttribute("type");
2239                if (formType != null) {
2240                    String fieldType = el.getAttribute("type");
2241                    if (fieldType == null) fieldType = "text-single";
2242
2243                    if (formType.equals("result") || fieldType.equals("fixed")) {
2244                        viewType = TYPE_RESULT_FIELD;
2245                    } else if (formType.equals("form")) {
2246                        if (fieldType.equals("boolean")) {
2247                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2248                                viewType = TYPE_BUTTON_GRID_FIELD;
2249                            } else {
2250                                viewType = TYPE_CHECKBOX_FIELD;
2251                            }
2252                        } else if (fieldType.equals("list-single")) {
2253                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
2254                            if (Option.forField(el).size() > 9) {
2255                                viewType = TYPE_SEARCH_LIST_FIELD;
2256                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
2257                                viewType = TYPE_BUTTON_GRID_FIELD;
2258                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
2259                                viewType = TYPE_RADIO_EDIT_FIELD;
2260                            } else {
2261                                viewType = TYPE_SPINNER_FIELD;
2262                            }
2263                        } else {
2264                            viewType = TYPE_TEXT_FIELD;
2265                        }
2266                    }
2267
2268                    return new Field(eu.siacs.conversations.xmpp.forms.Field.parse(el), viewType);
2269                }
2270
2271                return null;
2272            }
2273
2274            protected Item mkItem(Element el, int pos) {
2275                int viewType = -1;
2276
2277                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
2278                    if (el.getName().equals("note")) {
2279                        viewType = TYPE_NOTE;
2280                    } else if (el.getNamespace().equals("jabber:x:oob")) {
2281                        viewType = TYPE_WEB;
2282                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
2283                        viewType = TYPE_NOTE;
2284                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
2285                        Field field = mkField(el);
2286                        if (field != null) {
2287                            items.put(pos, field);
2288                            return field;
2289                        }
2290                    }
2291                } else if (response != null) {
2292                    viewType = TYPE_ERROR;
2293                }
2294
2295                Item item = new Item(el, viewType);
2296                items.put(pos, item);
2297                return item;
2298            }
2299
2300            class ActionsAdapter extends ArrayAdapter<Pair<String, String>> {
2301                protected Context ctx;
2302
2303                public ActionsAdapter(Context ctx) {
2304                    super(ctx, R.layout.simple_list_item);
2305                    this.ctx = ctx;
2306                }
2307
2308                @Override
2309                public View getView(int position, View convertView, ViewGroup parent) {
2310                    View v = super.getView(position, convertView, parent);
2311                    TextView tv = (TextView) v.findViewById(android.R.id.text1);
2312                    tv.setGravity(Gravity.CENTER);
2313                    tv.setText(getItem(position).second);
2314                    int resId = ctx.getResources().getIdentifier("action_" + getItem(position).first, "string" , ctx.getPackageName());
2315                    if (resId != 0 && getItem(position).second.equals(getItem(position).first)) tv.setText(ctx.getResources().getString(resId));
2316                    tv.setTextColor(ContextCompat.getColor(ctx, R.color.white));
2317                    tv.setBackgroundColor(UIHelper.getColorForName(getItem(position).first));
2318                    return v;
2319                }
2320
2321                public int getPosition(String s) {
2322                    for(int i = 0; i < getCount(); i++) {
2323                        if (getItem(i).first.equals(s)) return i;
2324                    }
2325                    return -1;
2326                }
2327
2328                public int countExceptCancel() {
2329                    int count = 0;
2330                    for(int i = 0; i < getCount(); i++) {
2331                        if (!getItem(i).first.equals("cancel")) count++;
2332                    }
2333                    return count;
2334                }
2335
2336                public void clearExceptCancel() {
2337                    Pair<String,String> cancelItem = null;
2338                    for(int i = 0; i < getCount(); i++) {
2339                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
2340                    }
2341                    clear();
2342                    if (cancelItem != null) add(cancelItem);
2343                }
2344            }
2345
2346            final int TYPE_ERROR = 1;
2347            final int TYPE_NOTE = 2;
2348            final int TYPE_WEB = 3;
2349            final int TYPE_RESULT_FIELD = 4;
2350            final int TYPE_TEXT_FIELD = 5;
2351            final int TYPE_CHECKBOX_FIELD = 6;
2352            final int TYPE_SPINNER_FIELD = 7;
2353            final int TYPE_RADIO_EDIT_FIELD = 8;
2354            final int TYPE_RESULT_CELL = 9;
2355            final int TYPE_PROGRESSBAR = 10;
2356            final int TYPE_SEARCH_LIST_FIELD = 11;
2357            final int TYPE_ITEM_CARD = 12;
2358            final int TYPE_BUTTON_GRID_FIELD = 13;
2359
2360            protected boolean loading = false;
2361            protected Timer loadingTimer = new Timer();
2362            protected String mTitle;
2363            protected String mNode;
2364            protected CommandPageBinding mBinding = null;
2365            protected IqPacket response = null;
2366            protected Element responseElement = null;
2367            protected List<Field> reported = null;
2368            protected SparseArray<Item> items = new SparseArray<>();
2369            protected XmppConnectionService xmppConnectionService;
2370            protected ActionsAdapter actionsAdapter;
2371            protected GridLayoutManager layoutManager;
2372            protected WebView actionToWebview = null;
2373            protected int fillableFieldCount = 0;
2374            protected IqPacket pendingResponsePacket = null;
2375
2376            CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
2377                loading();
2378                mTitle = title;
2379                mNode = node;
2380                this.xmppConnectionService = xmppConnectionService;
2381                if (mPager != null) setupLayoutManager();
2382                actionsAdapter = new ActionsAdapter(xmppConnectionService);
2383                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
2384                    @Override
2385                    public void onChanged() {
2386                        if (mBinding == null) return;
2387
2388                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
2389                    }
2390
2391                    @Override
2392                    public void onInvalidated() {}
2393                });
2394            }
2395
2396            public String getTitle() {
2397                return mTitle;
2398            }
2399
2400            public void updateWithResponse(final IqPacket iq) {
2401                if (getView().isAttachedToWindow()) {
2402                    getView().post(() -> updateWithResponseUiThread(iq));
2403                } else {
2404                    pendingResponsePacket = iq;
2405                }
2406            }
2407
2408            protected void updateWithResponseUiThread(final IqPacket iq) {
2409                this.loadingTimer.cancel();
2410                this.loadingTimer = new Timer();
2411                this.loading = false;
2412                this.responseElement = null;
2413                this.fillableFieldCount = 0;
2414                this.reported = null;
2415                this.response = iq;
2416                this.items.clear();
2417                this.actionsAdapter.clear();
2418                layoutManager.setSpanCount(1);
2419
2420                boolean actionsCleared = false;
2421                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
2422                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
2423                    if (mNode.equals("jabber:iq:register") && command.getAttribute("status") != null && command.getAttribute("status").equals("completed")) {
2424                        xmppConnectionService.createContact(getAccount().getRoster().getContact(iq.getFrom()), true);
2425                    }
2426
2427                    for (Element el : command.getChildren()) {
2428                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2429                            for (Element action : el.getChildren()) {
2430                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
2431                                if (action.getName().equals("execute")) continue;
2432
2433                                actionsAdapter.add(Pair.create(action.getName(), action.getName()));
2434                            }
2435                        }
2436                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
2437                            Data form = Data.parse(el);
2438                            String title = form.getTitle();
2439                            if (title != null) {
2440                                mTitle = title;
2441                                ConversationPagerAdapter.this.notifyDataSetChanged();
2442                            }
2443
2444                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
2445                                this.responseElement = el;
2446                                setupReported(el.findChild("reported", "jabber:x:data"));
2447                                if (mBinding != null) mBinding.form.setLayoutManager(setupLayoutManager());
2448                            }
2449
2450                            eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2451                            if (actionList != null) {
2452                                actionsAdapter.clear();
2453
2454                                for (Option action : actionList.getOptions()) {
2455                                    actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
2456                                }
2457                            }
2458
2459                            String fillableFieldType = null;
2460                            String fillableFieldValue = null;
2461                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
2462                                if ((field.getType() == null || (!field.getType().equals("hidden") && !field.getType().equals("fixed"))) && field.getFieldName() != null && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
2463                                    fillableFieldType = field.getType();
2464                                    fillableFieldValue = field.getValue();
2465                                    fillableFieldCount++;
2466                                }
2467                            }
2468
2469                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && (fillableFieldType.equals("list-single") || (fillableFieldType.equals("boolean") && fillableFieldValue == null))) {
2470                                actionsCleared = true;
2471                                actionsAdapter.clearExceptCancel();
2472                            }
2473                            break;
2474                        }
2475                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
2476                            String url = el.findChildContent("url", "jabber:x:oob");
2477                            if (url != null) {
2478                                String scheme = Uri.parse(url).getScheme();
2479                                if (scheme.equals("http") || scheme.equals("https")) {
2480                                    this.responseElement = el;
2481                                    break;
2482                                }
2483                            }
2484                        }
2485                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
2486                            this.responseElement = el;
2487                            break;
2488                        }
2489                    }
2490
2491                    if (responseElement == null && command.getAttribute("status") != null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
2492                        removeSession(this);
2493                        return;
2494                    }
2495
2496                    if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && !actionsCleared) {
2497                        // No actions have been given, but we are not done?
2498                        // This is probably a spec violation, but we should do *something*
2499                        actionsAdapter.add(Pair.create("execute", "execute"));
2500                    }
2501
2502                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
2503                        if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
2504                            actionsAdapter.add(Pair.create("close", "close"));
2505                        } else if (actionsAdapter.getPosition("cancel") < 0) {
2506                            actionsAdapter.insert(Pair.create("cancel", "cancel"), 0);
2507                        }
2508                    }
2509                }
2510
2511                if (actionsAdapter.isEmpty()) {
2512                    actionsAdapter.add(Pair.create("close", "close"));
2513                }
2514
2515                notifyDataSetChanged();
2516            }
2517
2518            protected void setupReported(Element el) {
2519                if (el == null) {
2520                    reported = null;
2521                    return;
2522                }
2523
2524                reported = new ArrayList<>();
2525                for (Element fieldEl : el.getChildren()) {
2526                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
2527                    reported.add(mkField(fieldEl));
2528                }
2529            }
2530
2531            @Override
2532            public int getItemCount() {
2533                if (loading) return 1;
2534                if (response == null) return 0;
2535                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
2536                    int i = 0;
2537                    for (Element el : responseElement.getChildren()) {
2538                        if (!el.getNamespace().equals("jabber:x:data")) continue;
2539                        if (el.getName().equals("title")) continue;
2540                        if (el.getName().equals("field")) {
2541                            String type = el.getAttribute("type");
2542                            if (type != null && type.equals("hidden")) continue;
2543                            if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2544                        }
2545
2546                        if (el.getName().equals("reported") || el.getName().equals("item")) {
2547                            if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2548                                if (el.getName().equals("reported")) continue;
2549                                i += 1;
2550                            } else {
2551                                if (reported != null) i += reported.size();
2552                            }
2553                            continue;
2554                        }
2555
2556                        i++;
2557                    }
2558                    return i;
2559                }
2560                return 1;
2561            }
2562
2563            public Item getItem(int position) {
2564                if (loading) return new Item(null, TYPE_PROGRESSBAR);
2565                if (items.get(position) != null) return items.get(position);
2566                if (response == null) return null;
2567
2568                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
2569                    if (responseElement.getNamespace().equals("jabber:x:data")) {
2570                        int i = 0;
2571                        for (Element el : responseElement.getChildren()) {
2572                            if (!el.getNamespace().equals("jabber:x:data")) continue;
2573                            if (el.getName().equals("title")) continue;
2574                            if (el.getName().equals("field")) {
2575                                String type = el.getAttribute("type");
2576                                if (type != null && type.equals("hidden")) continue;
2577                                if (el.getAttribute("var") != null && el.getAttribute("var").equals("http://jabber.org/protocol/commands#actions")) continue;
2578                            }
2579
2580                            if (el.getName().equals("reported") || el.getName().equals("item")) {
2581                                Cell cell = null;
2582
2583                                if (reported != null) {
2584                                    if ((layoutManager == null ? 1 : layoutManager.getSpanCount()) < reported.size()) {
2585                                        if (el.getName().equals("reported")) continue;
2586                                        if (i == position) {
2587                                            items.put(position, new Item(el, TYPE_ITEM_CARD));
2588                                            return items.get(position);
2589                                        }
2590                                    } else {
2591                                        if (reported.size() > position - i) {
2592                                            Field reportedField = reported.get(position - i);
2593                                            Element itemField = null;
2594                                            if (el.getName().equals("item")) {
2595                                                for (Element subel : el.getChildren()) {
2596                                                    if (subel.getAttribute("var").equals(reportedField.getVar())) {
2597                                                       itemField = subel;
2598                                                       break;
2599                                                    }
2600                                                }
2601                                            }
2602                                            cell = new Cell(reportedField, itemField);
2603                                        } else {
2604                                            i += reported.size();
2605                                            continue;
2606                                        }
2607                                    }
2608                                }
2609
2610                                if (cell != null) {
2611                                    items.put(position, cell);
2612                                    return cell;
2613                                }
2614                            }
2615
2616                            if (i < position) {
2617                                i++;
2618                                continue;
2619                            }
2620
2621                            return mkItem(el, position);
2622                        }
2623                    }
2624                }
2625
2626                return mkItem(responseElement == null ? response : responseElement, position);
2627            }
2628
2629            @Override
2630            public int getItemViewType(int position) {
2631                return getItem(position).viewType;
2632            }
2633
2634            @Override
2635            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
2636                switch(viewType) {
2637                    case TYPE_ERROR: {
2638                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2639                        return new ErrorViewHolder(binding);
2640                    }
2641                    case TYPE_NOTE: {
2642                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
2643                        return new NoteViewHolder(binding);
2644                    }
2645                    case TYPE_WEB: {
2646                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
2647                        return new WebViewHolder(binding);
2648                    }
2649                    case TYPE_RESULT_FIELD: {
2650                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
2651                        return new ResultFieldViewHolder(binding);
2652                    }
2653                    case TYPE_RESULT_CELL: {
2654                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
2655                        return new ResultCellViewHolder(binding);
2656                    }
2657                    case TYPE_ITEM_CARD: {
2658                        CommandItemCardBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_item_card, container, false);
2659                        return new ItemCardViewHolder(binding);
2660                    }
2661                    case TYPE_CHECKBOX_FIELD: {
2662                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
2663                        return new CheckboxFieldViewHolder(binding);
2664                    }
2665                    case TYPE_SEARCH_LIST_FIELD: {
2666                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
2667                        return new SearchListFieldViewHolder(binding);
2668                    }
2669                    case TYPE_RADIO_EDIT_FIELD: {
2670                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
2671                        return new RadioEditFieldViewHolder(binding);
2672                    }
2673                    case TYPE_SPINNER_FIELD: {
2674                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
2675                        return new SpinnerFieldViewHolder(binding);
2676                    }
2677                    case TYPE_BUTTON_GRID_FIELD: {
2678                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
2679                        return new ButtonGridFieldViewHolder(binding);
2680                    }
2681                    case TYPE_TEXT_FIELD: {
2682                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
2683                        return new TextFieldViewHolder(binding);
2684                    }
2685                    case TYPE_PROGRESSBAR: {
2686                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
2687                        return new ProgressBarViewHolder(binding);
2688                    }
2689                    default:
2690                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
2691                }
2692            }
2693
2694            @Override
2695            public void onBindViewHolder(ViewHolder viewHolder, int position) {
2696                viewHolder.bind(getItem(position));
2697            }
2698
2699            public View getView() {
2700                return mBinding.getRoot();
2701            }
2702
2703            public boolean validate() {
2704                int count = getItemCount();
2705                boolean isValid = true;
2706                for (int i = 0; i < count; i++) {
2707                    boolean oneIsValid = getItem(i).validate();
2708                    isValid = isValid && oneIsValid;
2709                }
2710                notifyDataSetChanged();
2711                return isValid;
2712            }
2713
2714            public boolean execute() {
2715                return execute("execute");
2716            }
2717
2718            public boolean execute(int actionPosition) {
2719                return execute(actionsAdapter.getItem(actionPosition).first);
2720            }
2721
2722            public boolean execute(String action) {
2723                if (!action.equals("cancel") && !action.equals("prev") && !validate()) return false;
2724
2725                if (response == null) return true;
2726                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
2727                if (command == null) return true;
2728                String status = command.getAttribute("status");
2729                if (status == null || (!status.equals("executing") && !action.equals("prev"))) return true;
2730
2731                if (actionToWebview != null && !action.equals("cancel")) {
2732                    actionToWebview.postWebMessage(new WebMessage("xmpp_xep0050/" + action), Uri.parse("*"));
2733                    return false;
2734                }
2735
2736                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
2737                packet.setTo(response.getFrom());
2738                final Element c = packet.addChild("command", Namespace.COMMANDS);
2739                c.setAttribute("node", mNode);
2740                c.setAttribute("sessionid", command.getAttribute("sessionid"));
2741
2742                String formType = responseElement == null ? null : responseElement.getAttribute("type");
2743                if (!action.equals("cancel") &&
2744                    !action.equals("prev") &&
2745                    responseElement != null &&
2746                    responseElement.getName().equals("x") &&
2747                    responseElement.getNamespace().equals("jabber:x:data") &&
2748                    formType != null && formType.equals("form")) {
2749
2750                    Data form = Data.parse(responseElement);
2751                    eu.siacs.conversations.xmpp.forms.Field actionList = form.getFieldByName("http://jabber.org/protocol/commands#actions");
2752                    if (actionList != null) {
2753                        actionList.setValue(action);
2754                        c.setAttribute("action", "execute");
2755                    }
2756
2757                    responseElement.setAttribute("type", "submit");
2758                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
2759                    if (rsm != null) {
2760                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
2761                        max.setContent("1000");
2762                        rsm.addChild(max);
2763                    }
2764
2765                    c.addChild(responseElement);
2766                }
2767
2768                if (c.getAttribute("action") == null) c.setAttribute("action", action);
2769
2770                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
2771                    updateWithResponse(iq);
2772                });
2773
2774                loading();
2775                return false;
2776            }
2777
2778            protected void loading() {
2779                loadingTimer.schedule(new TimerTask() {
2780                    @Override
2781                    public void run() {
2782                        getView().post(() -> {
2783                            loading = true;
2784                            notifyDataSetChanged();
2785                        });
2786                    }
2787                }, 500);
2788            }
2789
2790            protected GridLayoutManager setupLayoutManager() {
2791                int spanCount = 1;
2792
2793                if (reported != null && mPager != null) {
2794                    float screenWidth = mPager.getContext().getResources().getDisplayMetrics().widthPixels;
2795                    TextPaint paint = ((TextView) LayoutInflater.from(mPager.getContext()).inflate(R.layout.command_result_cell, null)).getPaint();
2796                    float tableHeaderWidth = reported.stream().reduce(
2797                        0f,
2798                        (total, field) -> total + StaticLayout.getDesiredWidth(field.getLabel().or("--------"), paint),
2799                        (a, b) -> a + b
2800                    );
2801
2802                    spanCount = tableHeaderWidth > 0.65 * screenWidth ? 1 : this.reported.size();
2803                }
2804
2805                if (layoutManager != null && layoutManager.getSpanCount() != spanCount) {
2806                    items.clear();
2807                    notifyDataSetChanged();
2808                }
2809
2810                layoutManager = new GridLayoutManager(mPager.getContext(), spanCount);
2811                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
2812                    @Override
2813                    public int getSpanSize(int position) {
2814                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
2815                        return 1;
2816                    }
2817                });
2818                return layoutManager;
2819            }
2820
2821            public void setBinding(CommandPageBinding b) {
2822                mBinding = b;
2823                // https://stackoverflow.com/a/32350474/8611
2824                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
2825                    @Override
2826                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
2827                        if(rv.getChildCount() > 0) {
2828                            int[] location = new int[2];
2829                            rv.getLocationOnScreen(location);
2830                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
2831                            if (childView instanceof ViewGroup) {
2832                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
2833                            }
2834                            int action = e.getAction();
2835                            switch (action) {
2836                                case MotionEvent.ACTION_DOWN:
2837                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(1)) || childView instanceof WebView) {
2838                                        rv.requestDisallowInterceptTouchEvent(true);
2839                                    }
2840                                case MotionEvent.ACTION_UP:
2841                                    if ((childView instanceof AbsListView && ((AbsListView) childView).canScrollList(-11)) || childView instanceof WebView) {
2842                                        rv.requestDisallowInterceptTouchEvent(true);
2843                                    }
2844                            }
2845                        }
2846
2847                        return false;
2848                    }
2849
2850                    @Override
2851                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }
2852
2853                    @Override
2854                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
2855                });
2856                mBinding.form.setLayoutManager(setupLayoutManager());
2857                mBinding.form.setAdapter(this);
2858                mBinding.actions.setAdapter(actionsAdapter);
2859                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
2860                    if (execute(pos)) {
2861                        removeSession(CommandSession.this);
2862                    }
2863                });
2864
2865                actionsAdapter.notifyDataSetChanged();
2866
2867                if (pendingResponsePacket != null) {
2868                    updateWithResponseUiThread(pendingResponsePacket);
2869                    pendingResponsePacket = null;
2870                }
2871            }
2872
2873            // https://stackoverflow.com/a/36037991/8611
2874            private View findViewAt(ViewGroup viewGroup, float x, float y) {
2875                for(int i = 0; i < viewGroup.getChildCount(); i++) {
2876                    View child = viewGroup.getChildAt(i);
2877                    if (child instanceof ViewGroup && !(child instanceof AbsListView) && !(child instanceof WebView)) {
2878                        View foundView = findViewAt((ViewGroup) child, x, y);
2879                        if (foundView != null && foundView.isShown()) {
2880                            return foundView;
2881                        }
2882                    } else {
2883                        int[] location = new int[2];
2884                        child.getLocationOnScreen(location);
2885                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
2886                        if (rect.contains((int)x, (int)y)) {
2887                            return child;
2888                        }
2889                    }
2890                }
2891
2892                return null;
2893            }
2894        }
2895    }
2896}