Conversation.java

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