Conversation.java

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