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