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