Conversation.java

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