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