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