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