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