Conversation.java

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