~singpolyma/cheogram-android

18e81af6ce49b8ce16ec7ff5a5489018c1fedf10 — Stephen Paul Weber 3 months ago 4ce49c9 + 4cc7069
Merge branch 'commands'

* commands: (50 commits)
  Tell the server we have a UI that can handle up to 1000 items
  For very long lists, use a searchable list view
  Use a list item background that can handle being selected
  Allow nested scrolling of ListView and WebView
  NPE
  Show any commands pushed along with a message
  Allow oob webview to move the execution forward
  Spinner when loading list of commands
  Show spinner when loading if it takes awhile
  Menu item to refresh features for a contact
  Order cells by reported order
  Look more like a real web browser
  Don't submit the form when cancelling
  We can still proceed with no form, just don't send a form
  Validate presence of required fields
  DRY up common patterns for fields
  Refactor to bind to an Item container, not just a raw Element
  Use more features of the TextInputLayout
  Support more text field types
  Support reported/item tables
  ...
35 files changed, 2224 insertions(+), 165 deletions(-)

A src/cheogram/java/com/cheogram/android/GridView.java
A src/cheogram/java/eu/siacs/conversations/xmpp/Option.java
A src/cheogram/res/drawable/list_choice.xml
A src/cheogram/res/layout/command_button.xml
A src/cheogram/res/layout/command_checkbox_field.xml
A src/cheogram/res/layout/command_note.xml
A src/cheogram/res/layout/command_page.xml
A src/cheogram/res/layout/command_progress_bar.xml
A src/cheogram/res/layout/command_radio_edit_field.xml
A src/cheogram/res/layout/command_result_cell.xml
A src/cheogram/res/layout/command_result_field.xml
A src/cheogram/res/layout/command_search_list_field.xml
A src/cheogram/res/layout/command_spinner_field.xml
A src/cheogram/res/layout/command_text_field.xml
A src/cheogram/res/layout/command_webview.xml
A src/cheogram/res/layout/radio_grid_item.xml
M src/cheogram/res/values/strings.xml
M src/main/java/eu/siacs/conversations/entities/Contact.java
M src/main/java/eu/siacs/conversations/entities/Conversation.java
M src/main/java/eu/siacs/conversations/entities/IndividualMessage.java
M src/main/java/eu/siacs/conversations/entities/Message.java
M src/main/java/eu/siacs/conversations/entities/Presences.java
M src/main/java/eu/siacs/conversations/generator/IqGenerator.java
M src/main/java/eu/siacs/conversations/parser/MessageParser.java
M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java
M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
A src/main/java/eu/siacs/conversations/ui/adapter/CommandAdapter.java
A src/main/java/eu/siacs/conversations/ui/adapter/CommandButtonAdapter.java
M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
A src/main/res/layout/command_row.xml
M src/main/res/layout/fragment_conversation.xml
M src/main/res/layout/message_content.xml
M src/main/res/layout/simple_list_item.xml
M src/main/res/menu/fragment_conversation.xml
A src/cheogram/java/com/cheogram/android/GridView.java => src/cheogram/java/com/cheogram/android/GridView.java +38 -0
@@ 0,0 1,38 @@
package com.cheogram.android;

import android.content.Context;
import android.util.AttributeSet;

// https://blog.jayway.com/2012/10/04/how-to-make-the-height-of-a-gridview-wrap-its-content/
public class GridView extends android.widget.GridView {
    public GridView(Context context) {
        super(context);
    }

    public GridView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public GridView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSpec;

        if (getLayoutParams().height == LayoutParams.WRAP_CONTENT) {
            // The great Android "hackatlon", the love, the magic.
            // The two leftmost bits in the height measure spec have
            // a special meaning, hence we can't use them to describe height.
            heightSpec = MeasureSpec.makeMeasureSpec(
                    Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
        }
        else {
            // Any other height should be respected as is.
            heightSpec = heightMeasureSpec;
        }

        super.onMeasure(widthMeasureSpec, heightSpec);
    }
}

A src/cheogram/java/eu/siacs/conversations/xmpp/Option.java => src/cheogram/java/eu/siacs/conversations/xmpp/Option.java +41 -0
@@ 0,0 1,41 @@
package eu.siacs.conversations.xmpp;

import java.util.ArrayList;
import java.util.List;
import eu.siacs.conversations.xml.Element;

public class Option {
    protected final String value;
    protected final String label;

    public static List<Option> forField(Element field) {
        List<Option> options = new ArrayList<>();
        for (Element el : field.getChildren()) {
            if (!el.getNamespace().equals("jabber:x:data")) continue;
            if (!el.getName().equals("option")) continue;
            options.add(new Option(el));
        }
        return options;
    }

    public Option(final Element option) {
        this(option.findChildContent("value", "jabber:x:data"), option.getAttribute("label"));
    }

    public Option(final String value, final String label) {
        this.value = value;
        this.label = label == null ? value : label;
    }

    public boolean equals(Object o) {
        if (!(o instanceof Option)) return false;

        if (value == ((Option) o).value) return true;
        if (value == null || ((Option) o).value == null) return false;
        return value.equals(((Option) o).value);
    }

    public String toString() { return label; }

    public String getValue() { return value; }
}

A src/cheogram/res/drawable/list_choice.xml => src/cheogram/res/drawable/list_choice.xml +9 -0
@@ 0,0 1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
    android:enterFadeDuration="@android:integer/config_shortAnimTime"
    android:exitFadeDuration="@android:integer/config_shortAnimTime">

    <item android:state_pressed="true" android:drawable="@color/grey500" />
    <item android:state_activated="true" android:drawable="@color/grey500" />
    <item android:drawable="@android:color/transparent" />
</selector>

A src/cheogram/res/layout/command_button.xml => src/cheogram/res/layout/command_button.xml +8 -0
@@ 0,0 1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
    android:id="@+id/command"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    style="?android:attr/buttonStyleSmall" />
</layout>

A src/cheogram/res/layout/command_checkbox_field.xml => src/cheogram/res/layout/command_checkbox_field.xml +49 -0
@@ 0,0 1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <RelativeLayout
        android:id="@+id/row"
        android:background="?selectableItemBackground"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:minHeight="?android:attr/listPreferredItemHeightSmall">

        <LinearLayout
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_toLeftOf="@+id/checkbox"
            android:layout_toStartOf="@+id/checkbox"
            android:orientation="vertical">

            <TextView
                android:id="@+id/label"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:paddingLeft="13dp"
                android:scrollHorizontally="false"
                android:textAppearance="@style/TextAppearance.Conversations.Subhead" />

            <TextView
                android:id="@+id/desc"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:paddingLeft="16dp"
                android:textAppearance="@style/TextAppearance.Conversations.Status"
                android:textColor="?android:textColorSecondary" />

        </LinearLayout>

        <CheckBox
            android:id="@+id/checkbox"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:layout_centerVertical="true"
            android:paddingRight="16dp"
            android:layout_marginLeft="16dp" />

    </RelativeLayout>
</layout>

A src/cheogram/res/layout/command_note.xml => src/cheogram/res/layout/command_note.xml +31 -0
@@ 0,0 1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="16dp"
        android:orientation="vertical">

        <ImageView
            android:visibility="gone"
            android:id="@+id/error_icon"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:layout_centerHorizontal="true"
            android:scaleType="fitCenter"
            android:src="@drawable/ic_send_cancel_dnd" />

        <TextView
            android:id="@+id/message"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:minHeight="?android:attr/listPreferredItemHeightSmall"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Body1"
            android:textColor="?attr/edit_text_color" />

    </LinearLayout>
</layout>

A src/cheogram/res/layout/command_page.xml => src/cheogram/res/layout/command_page.xml +29 -0
@@ 0,0 1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <RelativeLayout
        android:layout_width="fill_parent"
        android:layout_height="fill_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/form"
            android:paddingTop="8dp"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_above="@+id/actions"
            android:orientation="vertical"
            app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />

        <GridView
            android:id="@+id/actions"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_alignParentLeft="true"
            android:layout_alignParentBottom="true"
            android:horizontalSpacing="0dp"
            android:verticalSpacing="0dp"
            android:numColumns="2" />

    </RelativeLayout>
</layout>

A src/cheogram/res/layout/command_progress_bar.xml => src/cheogram/res/layout/command_progress_bar.xml +13 -0
@@ 0,0 1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

        <ProgressBar
            android:id="@+id/progressbar"
            android:layout_width="match_parent"
            android:layout_height="130dp"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingBottom="16dp" />

</layout>

A src/cheogram/res/layout/command_radio_edit_field.xml => src/cheogram/res/layout/command_radio_edit_field.xml +53 -0
@@ 0,0 1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="16dp"
        android:orientation="vertical">

        <TextView
            android:id="@+id/label"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="13dp"
            android:paddingRight="8dp"
            android:paddingBottom="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Subhead"
            android:textColor="?attr/edit_text_color" />

        <com.cheogram.android.GridView
            android:id="@+id/radios"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginRight="8dp"
            android:paddingLeft="8dp"
            android:horizontalSpacing="0dp"
            android:verticalSpacing="0dp"
            android:numColumns="auto_fit" />

        <EditText
            android:id="@+id/open"
            android:visibility="gone"
            style="@style/Widget.Conversations.EditText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_marginRight="8dp"
            android:ems="10"
            android:imeOptions="actionNext"
            android:inputType="textWebEditText"
            android:minLines="1" />

        <TextView
            android:id="@+id/desc"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="16dp"
            android:paddingRight="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Status"
            android:textColor="?android:textColorSecondary" />

    </LinearLayout>
</layout>

A src/cheogram/res/layout/command_result_cell.xml => src/cheogram/res/layout/command_result_cell.xml +12 -0
@@ 0,0 1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.Conversations.Body1"
        android:textColor="?attr/edit_text_color" />

</layout>

A src/cheogram/res/layout/command_result_field.xml => src/cheogram/res/layout/command_result_field.xml +42 -0
@@ 0,0 1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="16dp"
        android:orientation="vertical">

        <TextView
            android:id="@+id/label"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingBottom="16dp"
            android:textAppearance="@style/TextAppearance.Conversations.Body1"
            android:textColor="?attr/edit_text_color" />

        <ListView
            android:id="@+id/values"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_alignParentLeft="true"
            android:layout_alignParentTop="true"
            android:background="?attr/color_background_secondary"
            android:divider="@android:color/transparent"
            android:dividerHeight="0dp"></ListView>

        <TextView
            android:id="@+id/desc"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Status"
            android:textColor="?android:textColorSecondary" />

    </LinearLayout>
</layout>

A src/cheogram/res/layout/command_search_list_field.xml => src/cheogram/res/layout/command_search_list_field.xml +47 -0
@@ 0,0 1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="16dp"
        android:orientation="vertical">

        <TextView
            android:id="@+id/label"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="13dp"
            android:paddingRight="8dp"
            android:paddingBottom="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Subhead"
            android:textColor="?attr/edit_text_color" />

        <EditText
            android:id="@+id/search"
            style="@style/Widget.Conversations.EditText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_marginRight="8dp"
            android:ems="10"
            android:imeOptions="actionNext"
            android:minLines="1" />

        <ListView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:choiceMode="singleChoice" />

        <TextView
            android:id="@+id/desc"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="16dp"
            android:paddingRight="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Status"
            android:textColor="?android:textColorSecondary" />

    </LinearLayout>
</layout>

A src/cheogram/res/layout/command_spinner_field.xml => src/cheogram/res/layout/command_spinner_field.xml +38 -0
@@ 0,0 1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="16dp"
        android:orientation="vertical">

        <TextView
            android:id="@+id/label"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="13dp"
            android:paddingRight="8dp"
            android:paddingBottom="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Subhead"
            android:textColor="?attr/edit_text_color" />

        <Spinner
            android:id="@+id/spinner"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginRight="8dp"
            android:paddingLeft="8dp"
            android:paddingBottom="8dp" />

        <TextView
            android:id="@+id/desc"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="16dp"
            android:paddingRight="8dp"
            android:textAppearance="@style/TextAppearance.Conversations.Status"
            android:textColor="?android:textColorSecondary" />

    </LinearLayout>
</layout>

A src/cheogram/res/layout/command_text_field.xml => src/cheogram/res/layout/command_text_field.xml +29 -0
@@ 0,0 1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textinput_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingBottom="16dp"
            app:errorTextAppearance="@style/TextAppearance.Conversations.Design.Error"
            app:hintTextAppearance="@style/TextAppearance.Conversations.Design.Hint"
            app:helperTextTextAppearance="@style/TextAppearance.Conversations.Status"
            app:helperTextTextColor="?android:textColorSecondary">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/textinput"
                style="@style/Widget.Conversations.EditText"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ems="10"
                android:imeOptions="actionNext"
                android:inputType="textWebEditText"
                android:minLines="1" />

        </com.google.android.material.textfield.TextInputLayout>

</layout>

A src/cheogram/res/layout/command_webview.xml => src/cheogram/res/layout/command_webview.xml +24 -0
@@ 0,0 1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <RelativeLayout
        android:layout_width="fill_parent"
        android:layout_height="fill_parent">

        <WebView
            android:id="@+id/webview"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

        <ProgressBar
            android:id="@+id/progressbar"
            android:layout_width="match_parent"
            android:layout_height="130dp"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingBottom="16dp" />

    </RelativeLayout>

</layout>

A src/cheogram/res/layout/radio_grid_item.xml => src/cheogram/res/layout/radio_grid_item.xml +9 -0
@@ 0,0 1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <RadioButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</layout>

M src/cheogram/res/values/strings.xml => src/cheogram/res/values/strings.xml +6 -0
@@ 13,4 13,10 @@
    <string name="if_contact_is_nearby_use_qr">If your contact is nearby, they can also scan the code below to accept your invitation.</string>
    <string name="easy_invite_share_text">Join %1$s and chat with me: %2$s</string>
    <string name="share_invite_with">Share invite with…</string>
    <string name="action_cancel">Cancel</string>
    <string name="action_next">Next</string>
    <string name="action_prev">Back</string>
    <string name="action_complete">Finish</string>
    <string name="action_close">Close</string>
    <string name="action_execute">Go</string>
</resources>

M src/main/java/eu/siacs/conversations/entities/Contact.java => src/main/java/eu/siacs/conversations/entities/Contact.java +7 -0
@@ 292,6 292,13 @@ public class Contact implements ListItem, Blockable {
        return this.presences.getShownStatus();
    }

    public Jid resourceWhichSupport(final String namespace) {
        final String resource = getPresences().firstWhichSupport(namespace);
        if (resource == null) return null;

        return resource.equals("") ? getJid() : getJid().withResource(resource);
    }

    public boolean setPhotoUri(String uri) {
        if (uri != null && !uri.equals(this.photoUri)) {
            this.photoUri = uri;

M src/main/java/eu/siacs/conversations/entities/Conversation.java => src/main/java/eu/siacs/conversations/entities/Conversation.java +1249 -1
@@ 1,12 1,48 @@
package eu.siacs.conversations.entities;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.graphics.Rect;
import android.net.Uri;
import android.text.Editable;
import android.text.InputType;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.AdapterView;
import android.widget.CompoundButton;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Spinner;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.webkit.WebChromeClient;
import android.util.SparseArray;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import androidx.core.content.ContextCompat;
import androidx.databinding.DataBindingUtil;
import androidx.databinding.ViewDataBinding;
import androidx.viewpager.widget.PagerAdapter;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.viewpager.widget.ViewPager;

import com.google.android.material.tabs.TabLayout;
import com.google.android.material.textfield.TextInputLayout;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.Lists;



@@ 19,20 55,41 @@ import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import java.util.Timer;
import java.util.TimerTask;

import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.OmemoSetting;
import eu.siacs.conversations.crypto.PgpDecryptionService;
import eu.siacs.conversations.databinding.CommandPageBinding;
import eu.siacs.conversations.databinding.CommandNoteBinding;
import eu.siacs.conversations.databinding.CommandResultFieldBinding;
import eu.siacs.conversations.databinding.CommandResultCellBinding;
import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
import eu.siacs.conversations.databinding.CommandProgressBarBinding;
import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
import eu.siacs.conversations.databinding.CommandTextFieldBinding;
import eu.siacs.conversations.databinding.CommandWebviewBinding;
import eu.siacs.conversations.persistance.DatabaseBackend;
import eu.siacs.conversations.services.AvatarService;
import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.JidHelper;
import eu.siacs.conversations.utils.MessageUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.Option;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.mam.MamReference;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;

import static eu.siacs.conversations.entities.Bookmark.printableValue;



@@ 84,6 141,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
    private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
    private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
    private String mFirstMamReference = null;
    protected int mCurrentTab = -1;
    protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();

    public Conversation(final String name, final Account account, final Jid contactJid,
                        final int mode) {


@@ 1109,6 1168,36 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
        return getName().toString();
    }

    public void setCurrentTab(int tab) {
        mCurrentTab = tab;
    }

    public int getCurrentTab() {
        if (mCurrentTab >= 0) return mCurrentTab;

        if (!isRead() || getContact().resourceWhichSupport(Namespace.COMMANDS) == null) {
            return 0;
        }

        return 1;
    }

    public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
        pagerAdapter.startCommand(command, xmppConnectionService);
    }

    public void setupViewPager(ViewPager pager, TabLayout tabs) {
        pagerAdapter.setupViewPager(pager, tabs);
    }

    public void showViewPager() {
        pagerAdapter.show();
    }

    public void hideViewPager() {
        pagerAdapter.hide();
    }

    public interface OnMessageFound {
        void onMessageFound(final Message message);
    }


@@ 1130,4 1219,1163 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
            return message;
        }
    }

    public class ConversationPagerAdapter extends PagerAdapter {
        protected ViewPager mPager = null;
        protected TabLayout mTabs = null;
        ArrayList<CommandSession> sessions = new ArrayList<>();

        public void setupViewPager(ViewPager pager, TabLayout tabs) {
            mPager = pager;
            mTabs = tabs;
            show();
            pager.setAdapter(this);
            tabs.setupWithViewPager(mPager);
            pager.setCurrentItem(getCurrentTab());

            mPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
                public void onPageScrollStateChanged(int state) { }
                public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { }

                public void onPageSelected(int position) {
                    setCurrentTab(position);
                }
            });
        }

        public void show() {
            if (sessions == null) {
                sessions = new ArrayList<>();
                notifyDataSetChanged();
            }
            if (mTabs != null) mTabs.setVisibility(View.VISIBLE);
        }

        public void hide() {
            if (mPager != null) mPager.setCurrentItem(0);
            if (mTabs != null) mTabs.setVisibility(View.GONE);
            sessions = null;
            notifyDataSetChanged();
        }

        public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
            CommandSession session = new CommandSession(command.getAttribute("name"), xmppConnectionService);

            final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
            packet.setTo(command.getAttributeAsJid("jid"));
            final Element c = packet.addChild("command", Namespace.COMMANDS);
            c.setAttribute("node", command.getAttribute("node"));
            c.setAttribute("action", "execute");
            xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
                mPager.post(() -> {
                    session.updateWithResponse(iq);
                });
            });

            sessions.add(session);
            notifyDataSetChanged();
            mPager.setCurrentItem(getCount() - 1);
        }

        public void removeSession(CommandSession session) {
            sessions.remove(session);
            notifyDataSetChanged();
        }

        @NonNull
        @Override
        public Object instantiateItem(@NonNull ViewGroup container, int position) {
            if (position < 2) {
              return mPager.getChildAt(position);
            }

            CommandSession session = sessions.get(position-2);
            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
            container.addView(binding.getRoot());
            session.setBinding(binding);
            return session;
        }

        @Override
        public void destroyItem(@NonNull ViewGroup container, int position, Object o) {
            if (position < 2) return;

            container.removeView(((CommandSession) o).getView());
        }

        @Override
        public int getItemPosition(Object o) {
            if (o == mPager.getChildAt(0)) return PagerAdapter.POSITION_UNCHANGED;
            if (o == mPager.getChildAt(1)) return PagerAdapter.POSITION_UNCHANGED;

            int pos = sessions.indexOf(o);
            if (pos < 0) return PagerAdapter.POSITION_NONE;
            return pos + 2;
        }

        @Override
        public int getCount() {
            if (sessions == null) return 1;

            int count = 2 + sessions.size();
            if (count > 2) {
                mTabs.setTabMode(TabLayout.MODE_SCROLLABLE);
            } else {
                mTabs.setTabMode(TabLayout.MODE_FIXED);
            }
            return count;
        }

        @Override
        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
            if (view == o) return true;

            if (o instanceof CommandSession) {
                return ((CommandSession) o).getView() == view;
            }

            return false;
        }

        @Nullable
        @Override
        public CharSequence getPageTitle(int position) {
            switch (position) {
                case 0:
                    return "Conversation";
                case 1:
                    return "Commands";
                default:
                    CommandSession session = sessions.get(position-2);
                    if (session == null) return super.getPageTitle(position);
                    return session.getTitle();
            }
        }

        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
            abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
                protected T binding;

                public ViewHolder(T binding) {
                    super(binding.getRoot());
                    this.binding = binding;
                }

                abstract public void bind(Item el);

                protected void setTextOrHide(TextView v, Optional<String> s) {
                    if (s == null || !s.isPresent()) {
                        v.setVisibility(View.GONE);
                    } else {
                        v.setVisibility(View.VISIBLE);
                        v.setText(s.get());
                    }
                }

                protected void setupInputType(Element field, TextView textinput, TextInputLayout layout) {
                    int flags = 0;
                    if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_NONE);
                    textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);

                    String type = field.getAttribute("type");
                    if (type != null) {
                        if (type.equals("text-multi") || type.equals("jid-multi")) {
                            flags |= InputType.TYPE_TEXT_FLAG_MULTI_LINE;
                        }

                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);

                        if (type.equals("jid-single") || type.equals("jid-multi")) {
                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
                        }

                        if (type.equals("text-private")) {
                            textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
                            if (layout != null) layout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE);
                        }
                    }

                    Element validate = field.findChild("validate", "http://jabber.org/protocol/xdata-validate");
                    if (validate == null) return;
                    String datatype = validate.getAttribute("datatype");
                    if (datatype == null) return;

                    if (datatype.equals("xs:integer") || datatype.equals("xs:int") || datatype.equals("xs:long") || datatype.equals("xs:short") || datatype.equals("xs:byte")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
                    }

                    if (datatype.equals("xs:decimal") || datatype.equals("xs:double")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_NUMBER_FLAG_DECIMAL);
                    }

                    if (datatype.equals("xs:date")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_DATE);
                    }

                    if (datatype.equals("xs:dateTime")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_NORMAL);
                    }

                    if (datatype.equals("xs:time")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_DATETIME | InputType.TYPE_DATETIME_VARIATION_TIME);
                    }

                    if (datatype.equals("xs:anyURI")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
                    }

                    if (datatype.equals("html:tel")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_PHONE);
                    }

                    if (datatype.equals("html:email")) {
                        textinput.setInputType(flags | InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
                    }
                }
            }

            class ErrorViewHolder extends ViewHolder<CommandNoteBinding> {
                public ErrorViewHolder(CommandNoteBinding binding) { super(binding); }

                @Override
                public void bind(Item iq) {
                    binding.errorIcon.setVisibility(View.VISIBLE);

                    Element error = iq.el.findChild("error");
                    if (error == null) return;
                    String text = error.findChildContent("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
                    if (text == null || text.equals("")) {
                        text = error.getChildren().get(0).getName();
                    }
                    binding.message.setText(text);
                }
            }

            class NoteViewHolder extends ViewHolder<CommandNoteBinding> {
                public NoteViewHolder(CommandNoteBinding binding) { super(binding); }

                @Override
                public void bind(Item note) {
                    binding.message.setText(note.el.getContent());

                    String type = note.el.getAttribute("type");
                    if (type != null && type.equals("error")) {
                        binding.errorIcon.setVisibility(View.VISIBLE);
                    }
                }
            }

            class ResultFieldViewHolder extends ViewHolder<CommandResultFieldBinding> {
                public ResultFieldViewHolder(CommandResultFieldBinding binding) { super(binding); }

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    setTextOrHide(binding.label, field.getLabel());
                    setTextOrHide(binding.desc, field.getDesc());

                    ArrayAdapter<String> values = new ArrayAdapter<String>(binding.getRoot().getContext(), R.layout.simple_list_item);
                    for (Element el : field.el.getChildren()) {
                        if (el.getName().equals("value") && el.getNamespace().equals("jabber:x:data")) {
                            values.add(el.getContent());
                        }
                    }
                    binding.values.setAdapter(values);

                    ClipboardManager clipboard = binding.getRoot().getContext().getSystemService(ClipboardManager.class);
                    binding.values.setOnItemLongClickListener((arg0, arg1, pos, id) -> {
                        ClipData myClip = ClipData.newPlainText("text", values.getItem(pos));
                        clipboard.setPrimaryClip(myClip);
                        Toast.makeText(binding.getRoot().getContext(), R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
                        return true;
                    });
                }
            }

            class ResultCellViewHolder extends ViewHolder<CommandResultCellBinding> {
                public ResultCellViewHolder(CommandResultCellBinding binding) { super(binding); }

                @Override
                public void bind(Item item) {
                    Cell cell = (Cell) item;

                    if (cell.el == null) {
                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Subhead);
                        setTextOrHide(binding.text, cell.reported.getLabel());
                    } else {
                        binding.text.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Body1);
                        binding.text.setText(cell.el.findChildContent("value", "jabber:x:data"));
                    }
                }
            }

            class CheckboxFieldViewHolder extends ViewHolder<CommandCheckboxFieldBinding> implements CompoundButton.OnCheckedChangeListener {
                public CheckboxFieldViewHolder(CommandCheckboxFieldBinding binding) {
                    super(binding);
                    binding.row.setOnClickListener((v) -> {
                        binding.checkbox.toggle();
                    });
                    binding.checkbox.setOnCheckedChangeListener(this);
                }
                protected Element mValue = null;

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    binding.label.setText(field.getLabel().orElse(""));
                    setTextOrHide(binding.desc, field.getDesc());
                    mValue = field.getValue();
                    binding.checkbox.setChecked(mValue.getContent() != null && (mValue.getContent().equals("true") || mValue.getContent().equals("1")));
                }

                @Override
                public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
                    if (mValue == null) return;

                    mValue.setContent(isChecked ? "true" : "false");
                }
            }

            class SearchListFieldViewHolder extends ViewHolder<CommandSearchListFieldBinding> implements TextWatcher {
                public SearchListFieldViewHolder(CommandSearchListFieldBinding binding) {
                    super(binding);
                    binding.search.addTextChangedListener(this);
                }
                protected Element mValue = null;
                List<Option> options = new ArrayList<>();
                protected ArrayAdapter<Option> adapter;
                protected boolean open;

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    setTextOrHide(binding.label, field.getLabel());
                    setTextOrHide(binding.desc, field.getDesc());

                    if (field.error != null) {
                        binding.desc.setVisibility(View.VISIBLE);
                        binding.desc.setText(field.error);
                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Design_Error);
                    } else {
                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Status);
                    }

                    mValue = field.getValue();

                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
                    open = validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null;
                    setupInputType(field.el, binding.search, null);

                    options = field.getOptions();
                    binding.list.setOnItemClickListener((parent, view, position, id) -> {
                        mValue.setContent(adapter.getItem(binding.list.getCheckedItemPosition()).getValue());
                        if (open) binding.search.setText(mValue.getContent());
                    });
                    search("");
                }

                @Override
                public void afterTextChanged(Editable s) {
                    if (open) mValue.setContent(s.toString());
                    search(s.toString());
                }

                @Override
                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }

                @Override
                public void onTextChanged(CharSequence s, int start, int count, int after) { }

                protected void search(String s) {
                    List<Option> filteredOptions;
                    final String q = s.replaceAll("\\W", "").toLowerCase();
                    if (q == null || q.equals("")) {
                        filteredOptions = options;
                    } else {
                        filteredOptions = options.stream().filter(o -> o.toString().replaceAll("\\W", "").toLowerCase().contains(q)).collect(Collectors.toList());
                    }
                    adapter = new ArrayAdapter(binding.getRoot().getContext(), R.layout.simple_list_item, filteredOptions);
                    binding.list.setAdapter(adapter);

                    int checkedPos = filteredOptions.indexOf(new Option(mValue.getContent(), ""));
                    if (checkedPos >= 0) binding.list.setItemChecked(checkedPos, true);
                }
            }

            class RadioEditFieldViewHolder extends ViewHolder<CommandRadioEditFieldBinding> implements CompoundButton.OnCheckedChangeListener, TextWatcher {
                public RadioEditFieldViewHolder(CommandRadioEditFieldBinding binding) {
                    super(binding);
                    binding.open.addTextChangedListener(this);
                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.radio_grid_item) {
                        @Override
                        public View getView(int position, View convertView, ViewGroup parent) {
                            CompoundButton v = (CompoundButton) super.getView(position, convertView, parent);
                            v.setId(position);
                            v.setChecked(getItem(position).getValue().equals(mValue.getContent()));
                            v.setOnCheckedChangeListener(RadioEditFieldViewHolder.this);
                            return v;
                        }
                    };
                }
                protected Element mValue = null;
                protected ArrayAdapter<Option> options;

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    setTextOrHide(binding.label, field.getLabel());
                    setTextOrHide(binding.desc, field.getDesc());

                    if (field.error != null) {
                        binding.desc.setVisibility(View.VISIBLE);
                        binding.desc.setText(field.error);
                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Design_Error);
                    } else {
                        binding.desc.setTextAppearance(R.style.TextAppearance_Conversations_Status);
                    }

                    mValue = field.getValue();

                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
                    binding.open.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
                    binding.open.setText(mValue.getContent());
                    setupInputType(field.el, binding.open, null);

                    options.clear();
                    List<Option> theOptions = field.getOptions();
                    options.addAll(theOptions);

                    float screenWidth = binding.getRoot().getContext().getResources().getDisplayMetrics().widthPixels;
                    TextPaint paint = ((TextView) LayoutInflater.from(binding.getRoot().getContext()).inflate(R.layout.radio_grid_item, null)).getPaint();
                    float maxColumnWidth = theOptions.stream().map((x) ->
                        StaticLayout.getDesiredWidth(x.toString(), paint)
                    ).max(Float::compare).orElse(new Float(0.0));
                    if (maxColumnWidth * theOptions.size() < 0.90 * screenWidth) {
                        binding.radios.setNumColumns(theOptions.size());
                    } else if (maxColumnWidth * (theOptions.size() / 2) < 0.90 * screenWidth) {
                        binding.radios.setNumColumns(theOptions.size() / 2);
                    } else {
                        binding.radios.setNumColumns(1);
                    }
                    binding.radios.setAdapter(options);
                }

                @Override
                public void onCheckedChanged(CompoundButton radio, boolean isChecked) {
                    if (mValue == null) return;

                    if (isChecked) {
                        mValue.setContent(options.getItem(radio.getId()).getValue());
                        binding.open.setText(mValue.getContent());
                    }
                    options.notifyDataSetChanged();
                }

                @Override
                public void afterTextChanged(Editable s) {
                    if (mValue == null) return;

                    mValue.setContent(s.toString());
                    options.notifyDataSetChanged();
                }

                @Override
                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }

                @Override
                public void onTextChanged(CharSequence s, int start, int count, int after) { }
            }

            class SpinnerFieldViewHolder extends ViewHolder<CommandSpinnerFieldBinding> implements AdapterView.OnItemSelectedListener {
                public SpinnerFieldViewHolder(CommandSpinnerFieldBinding binding) {
                    super(binding);
                    binding.spinner.setOnItemSelectedListener(this);
                }
                protected Element mValue = null;

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    setTextOrHide(binding.label, field.getLabel());
                    binding.spinner.setPrompt(field.getLabel().orElse(""));
                    setTextOrHide(binding.desc, field.getDesc());

                    mValue = field.getValue();

                    ArrayAdapter<Option> options = new ArrayAdapter<Option>(binding.getRoot().getContext(), android.R.layout.simple_spinner_item);
                    options.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
                    options.addAll(field.getOptions());

                    binding.spinner.setAdapter(options);
                    binding.spinner.setSelection(options.getPosition(new Option(mValue.getContent(), null)));
                }

                @Override
                public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
                    Option o = (Option) parent.getItemAtPosition(pos);
                    if (mValue == null) return;

                    mValue.setContent(o == null ? "" : o.getValue());
                }

                @Override
                public void onNothingSelected(AdapterView<?> parent) {
                    mValue.setContent("");
                }
            }

            class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
                public TextFieldViewHolder(CommandTextFieldBinding binding) {
                    super(binding);
                    binding.textinput.addTextChangedListener(this);
                }
                protected Element mValue = null;

                @Override
                public void bind(Item item) {
                    Field field = (Field) item;
                    binding.textinputLayout.setHint(field.getLabel().orElse(""));

                    binding.textinputLayout.setHelperTextEnabled(field.getDesc().isPresent());
                    field.getDesc().ifPresent(binding.textinputLayout::setHelperText);

                    binding.textinputLayout.setErrorEnabled(field.error != null);
                    if (field.error != null) binding.textinputLayout.setError(field.error);

                    mValue = field.getValue();
                    binding.textinput.setText(mValue.getContent());
                    setupInputType(field.el, binding.textinput, binding.textinputLayout);
                }

                @Override
                public void afterTextChanged(Editable s) {
                    if (mValue == null) return;

                    mValue.setContent(s.toString());
                }

                @Override
                public void beforeTextChanged(CharSequence s, int start, int count, int after) { }

                @Override
                public void onTextChanged(CharSequence s, int start, int count, int after) { }
            }

            class WebViewHolder extends ViewHolder<CommandWebviewBinding> {
                public WebViewHolder(CommandWebviewBinding binding) { super(binding); }

                @Override
                public void bind(Item oob) {
                    binding.webview.getSettings().setJavaScriptEnabled(true);
                    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");
                    binding.webview.getSettings().setDatabaseEnabled(true);
                    binding.webview.getSettings().setDomStorageEnabled(true);
                    binding.webview.setWebChromeClient(new WebChromeClient() {
                        @Override
                        public void onProgressChanged(WebView view, int newProgress) {
                            binding.progressbar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE);
                            binding.progressbar.setProgress(newProgress);
                        }
                    });
                    binding.webview.setWebViewClient(new WebViewClient() {
                        @Override
                        public void onPageFinished(WebView view, String url) {
                            super.onPageFinished(view, url);
                            mTitle = view.getTitle();
                            ConversationPagerAdapter.this.notifyDataSetChanged();
                        }
                    });
                    binding.webview.addJavascriptInterface(new JsObject(), "xmpp_xep0050");
                    binding.webview.loadUrl(oob.el.findChildContent("url", "jabber:x:oob"));
                }

                class JsObject {
                    @JavascriptInterface
                    public void execute() { execute("execute"); }
                    public void execute(String action) {
                        getView().post(() -> {
                            if(CommandSession.this.execute(action)) {
                                removeSession(CommandSession.this);
                            }
                        });
                    }
                }
            }

            class ProgressBarViewHolder extends ViewHolder<CommandProgressBarBinding> {
                public ProgressBarViewHolder(CommandProgressBarBinding binding) { super(binding); }

                @Override
                public void bind(Item item) { }
            }

            class Item {
                protected Element el;
                protected int viewType;
                protected String error = null;

                Item(Element el, int viewType) {
                    this.el = el;
                    this.viewType = viewType;
                }

                public boolean validate() {
                    error = null;
                    return true;
                }
            }

            class Field extends Item {
                Field(Element el, int viewType) { super(el, viewType); }

                @Override
                public boolean validate() {
                    if (!super.validate()) return false;
                    if (el.findChild("required", "jabber:x:data") == null) return true;
                    if (getValue().getContent() != null && !getValue().getContent().equals("")) return true;

                    error = "this value is required";
                    return false;
                }

                public String getVar() {
                    return el.getAttribute("var");
                }

                public Optional<String> getLabel() {
                    String label = el.getAttribute("label");
                    if (label == null) label = getVar();
                    return Optional.ofNullable(label);
                }

                public Optional<String> getDesc() {
                    return Optional.ofNullable(el.findChildContent("desc", "jabber:x:data"));
                }

                public Element getValue() {
                    Element value = el.findChild("value", "jabber:x:data");
                    if (value == null) {
                        value = el.addChild("value", "jabber:x:data");
                    }
                    return value;
                }

                public List<Option> getOptions() {
                    return Option.forField(el);
                }
            }

            class Cell extends Item {
                protected Field reported;

                Cell(Field reported, Element item) {
                    super(item, TYPE_RESULT_CELL);
                    this.reported = reported;
                }
            }

            protected Field mkField(Element el) {
                int viewType = -1;

                String formType = responseElement.getAttribute("type");
                if (formType != null) {
                    String fieldType = el.getAttribute("type");
                    if (fieldType == null) fieldType = "text-single";

                    if (formType.equals("result") || fieldType.equals("fixed")) {
                        viewType = TYPE_RESULT_FIELD;
                    } else if (formType.equals("form")) {
                        if (fieldType.equals("boolean")) {
                            viewType = TYPE_CHECKBOX_FIELD;
                        } else if (fieldType.equals("list-single")) {
                            Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
                            if (Option.forField(el).size() > 9) {
                                viewType = TYPE_SEARCH_LIST_FIELD;
                            } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
                                viewType = TYPE_RADIO_EDIT_FIELD;
                            } else {
                                viewType = TYPE_SPINNER_FIELD;
                            }
                        } else {
                            viewType = TYPE_TEXT_FIELD;
                        }
                    }

                    return new Field(el, viewType);
                }

                return null;
            }

            protected Item mkItem(Element el, int pos) {
                int viewType = -1;

                if (response != null && response.getType() == IqPacket.TYPE.RESULT) {
                    if (el.getName().equals("note")) {
                        viewType = TYPE_NOTE;
                    } else if (el.getNamespace().equals("jabber:x:oob")) {
                        viewType = TYPE_WEB;
                    } else if (el.getName().equals("instructions") && el.getNamespace().equals("jabber:x:data")) {
                        viewType = TYPE_NOTE;
                    } else if (el.getName().equals("field") && el.getNamespace().equals("jabber:x:data")) {
                        Field field = mkField(el);
                        if (field != null) {
                            items.put(pos, field);
                            return field;
                        }
                    }
                } else if (response != null) {
                    viewType = TYPE_ERROR;
                }

                Item item = new Item(el, viewType);
                items.put(pos, item);
                return item;
            }

            final int TYPE_ERROR = 1;
            final int TYPE_NOTE = 2;
            final int TYPE_WEB = 3;
            final int TYPE_RESULT_FIELD = 4;
            final int TYPE_TEXT_FIELD = 5;
            final int TYPE_CHECKBOX_FIELD = 6;
            final int TYPE_SPINNER_FIELD = 7;
            final int TYPE_RADIO_EDIT_FIELD = 8;
            final int TYPE_RESULT_CELL = 9;
            final int TYPE_PROGRESSBAR = 10;
            final int TYPE_SEARCH_LIST_FIELD = 11;

            protected boolean loading = false;
            protected Timer loadingTimer = new Timer();
            protected String mTitle;
            protected CommandPageBinding mBinding = null;
            protected IqPacket response = null;
            protected Element responseElement = null;
            protected List<Field> reported = null;
            protected SparseArray<Item> items = new SparseArray<>();
            protected XmppConnectionService xmppConnectionService;
            protected ArrayAdapter<String> actionsAdapter;
            protected GridLayoutManager layoutManager;

            CommandSession(String title, XmppConnectionService xmppConnectionService) {
                loading();
                mTitle = title;
                this.xmppConnectionService = xmppConnectionService;
                setupLayoutManager();
                actionsAdapter = new ArrayAdapter<String>(xmppConnectionService, R.layout.simple_list_item) {
                    @Override
                    public View getView(int position, View convertView, ViewGroup parent) {
                        View v = super.getView(position, convertView, parent);
                        TextView tv = (TextView) v.findViewById(android.R.id.text1);
                        tv.setGravity(Gravity.CENTER);
                        int resId = xmppConnectionService.getResources().getIdentifier("action_" + tv.getText() , "string" , xmppConnectionService.getPackageName());
                        if (resId != 0) tv.setText(xmppConnectionService.getResources().getString(resId));
                        tv.setTextColor(ContextCompat.getColor(xmppConnectionService, R.color.white));
                        tv.setBackgroundColor(UIHelper.getColorForName(tv.getText().toString()));
                        return v;
                    }
                };
                actionsAdapter.registerDataSetObserver(new DataSetObserver() {
                    @Override
                    public void onChanged() {
                        if (mBinding == null) return;

                        mBinding.actions.setNumColumns(actionsAdapter.getCount() > 1 ? 2 : 1);
                    }

                    @Override
                    public void onInvalidated() {}
                });
            }

            public String getTitle() {
                return mTitle;
            }

            public void updateWithResponse(IqPacket iq) {
                this.loadingTimer.cancel();
                this.loadingTimer = new Timer();
                this.loading = false;
                this.responseElement = null;
                this.reported = null;
                this.response = iq;
                this.items.clear();
                this.actionsAdapter.clear();
                layoutManager.setSpanCount(1);

                Element command = iq.findChild("command", "http://jabber.org/protocol/commands");
                if (iq.getType() == IqPacket.TYPE.RESULT && command != null) {
                    for (Element el : command.getChildren()) {
                        if (el.getName().equals("actions") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
                            for (Element action : el.getChildren()) {
                                if (!el.getNamespace().equals("http://jabber.org/protocol/commands")) continue;
                                if (action.getName().equals("execute")) continue;

                                actionsAdapter.add(action.getName());
                            }
                        }
                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:data")) {
                            String title = el.findChildContent("title", "jabber:x:data");
                            if (title != null) {
                                mTitle = title;
                                ConversationPagerAdapter.this.notifyDataSetChanged();
                            }

                            if (el.getAttribute("type").equals("result") || el.getAttribute("type").equals("form")) {
                                this.responseElement = el;
                                setupReported(el.findChild("reported", "jabber:x:data"));
                                layoutManager.setSpanCount(this.reported == null ? 1 : this.reported.size());
                            }
                            break;
                        }
                        if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
                            String url = el.findChildContent("url", "jabber:x:oob");
                            if (url != null) {
                                String scheme = Uri.parse(url).getScheme();
                                if (scheme.equals("http") || scheme.equals("https")) {
                                    this.responseElement = el;
                                    break;
                                }
                            }
                        }
                        if (el.getName().equals("note") && el.getNamespace().equals("http://jabber.org/protocol/commands")) {
                            this.responseElement = el;
                            break;
                        }
                    }

                    if (responseElement == null && (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled"))) {
                        removeSession(this);
                        return;
                    }

                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
                        // No actions have been given, but we are not done?
                        // This is probably a spec violation, but we should do *something*
                        actionsAdapter.add("execute");
                    }
                }

                if (actionsAdapter.getCount() > 0) {
                    if (actionsAdapter.getPosition("cancel") < 0) actionsAdapter.insert("cancel", 0);
                } else {
                    actionsAdapter.add("close");
                }

                notifyDataSetChanged();
            }

            protected void setupReported(Element el) {
                if (el == null) {
                    reported = null;
                    return;
                }

                reported = new ArrayList<>();
                for (Element fieldEl : el.getChildren()) {
                    if (!fieldEl.getName().equals("field") || !fieldEl.getNamespace().equals("jabber:x:data")) continue;
                    reported.add(mkField(fieldEl));
                }
            }

            @Override
            public int getItemCount() {
                if (loading) return 1;
                if (response == null) return 0;
                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null && responseElement.getNamespace().equals("jabber:x:data")) {
                    int i = 0;
                    for (Element el : responseElement.getChildren()) {
                        if (!el.getNamespace().equals("jabber:x:data")) continue;
                        if (el.getName().equals("title")) continue;
                        if (el.getName().equals("field")) {
                            String type = el.getAttribute("type");
                            if (type != null && type.equals("hidden")) continue;
                        }

                        if (el.getName().equals("reported") || el.getName().equals("item")) {
                            if (reported != null) i += reported.size();
                            continue;
                        }

                        i++;
                    }
                    return i;
                }
                return 1;
            }

            public Item getItem(int position) {
                if (loading) return new Item(null, TYPE_PROGRESSBAR);
                if (items.get(position) != null) return items.get(position);
                if (response == null) return null;

                if (response.getType() == IqPacket.TYPE.RESULT && responseElement != null) {
                    if (responseElement.getNamespace().equals("jabber:x:data")) {
                        int i = 0;
                        for (Element el : responseElement.getChildren()) {
                            if (!el.getNamespace().equals("jabber:x:data")) continue;
                            if (el.getName().equals("title")) continue;
                            if (el.getName().equals("field")) {
                                String type = el.getAttribute("type");
                                if (type != null && type.equals("hidden")) continue;
                            }

                            if (el.getName().equals("reported") || el.getName().equals("item")) {
                                Cell cell = null;

                                if (reported != null) {
                                    if (reported.size() > position - i) {
                                        Field reportedField = reported.get(position - i);
                                        Element itemField = null;
                                        if (el.getName().equals("item")) {
                                            for (Element subel : el.getChildren()) {
                                                if (subel.getAttribute("var").equals(reportedField.getVar())) {
                                                   itemField = subel;
                                                   break;
                                                }
                                            }
                                        }
                                        cell = new Cell(reportedField, itemField);
                                    } else {
                                        i += reported.size();
                                        continue;
                                    }
                                }

                                if (cell != null) {
                                    items.put(position, cell);
                                    return cell;
                                }
                            }

                            if (i < position) {
                                i++;
                                continue;
                            }

                            return mkItem(el, position);
                        }
                    }
                }

                return mkItem(responseElement == null ? response : responseElement, position);
            }

            @Override
            public int getItemViewType(int position) {
                return getItem(position).viewType;
            }

            @Override
            public ViewHolder onCreateViewHolder(ViewGroup container, int viewType) {
                switch(viewType) {
                    case TYPE_ERROR: {
                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
                        return new ErrorViewHolder(binding);
                    }
                    case TYPE_NOTE: {
                        CommandNoteBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_note, container, false);
                        return new NoteViewHolder(binding);
                    }
                    case TYPE_WEB: {
                        CommandWebviewBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_webview, container, false);
                        return new WebViewHolder(binding);
                    }
                    case TYPE_RESULT_FIELD: {
                        CommandResultFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_field, container, false);
                        return new ResultFieldViewHolder(binding);
                    }
                    case TYPE_RESULT_CELL: {
                        CommandResultCellBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_result_cell, container, false);
                        return new ResultCellViewHolder(binding);
                    }
                    case TYPE_CHECKBOX_FIELD: {
                        CommandCheckboxFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_checkbox_field, container, false);
                        return new CheckboxFieldViewHolder(binding);
                    }
                    case TYPE_SEARCH_LIST_FIELD: {
                        CommandSearchListFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_search_list_field, container, false);
                        return new SearchListFieldViewHolder(binding);
                    }
                    case TYPE_RADIO_EDIT_FIELD: {
                        CommandRadioEditFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_radio_edit_field, container, false);
                        return new RadioEditFieldViewHolder(binding);
                    }
                    case TYPE_SPINNER_FIELD: {
                        CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
                        return new SpinnerFieldViewHolder(binding);
                    }
                    case TYPE_TEXT_FIELD: {
                        CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
                        return new TextFieldViewHolder(binding);
                    }
                    case TYPE_PROGRESSBAR: {
                        CommandProgressBarBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_progress_bar, container, false);
                        return new ProgressBarViewHolder(binding);
                    }
                    default:
                        throw new IllegalArgumentException("Unknown viewType: " + viewType);
                }
            }

            @Override
            public void onBindViewHolder(ViewHolder viewHolder, int position) {
                viewHolder.bind(getItem(position));
            }

            public View getView() {
                return mBinding.getRoot();
            }

            public boolean validate() {
                int count = getItemCount();
                boolean isValid = true;
                for (int i = 0; i < count; i++) {
                    boolean oneIsValid = getItem(i).validate();
                    isValid = isValid && oneIsValid;
                }
                notifyDataSetChanged();
                return isValid;
            }

            public boolean execute() {
                return execute("execute");
            }

            public boolean execute(int actionPosition) {
                return execute(actionsAdapter.getItem(actionPosition));
            }

            public boolean execute(String action) {
                if (!action.equals("cancel") && !validate()) return false;
                if (response == null) return true;
                Element command = response.findChild("command", "http://jabber.org/protocol/commands");
                if (command == null) return true;
                String status = command.getAttribute("status");
                if (status == null || !status.equals("executing")) return true;

                final IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
                packet.setTo(response.getFrom());
                final Element c = packet.addChild("command", Namespace.COMMANDS);
                c.setAttribute("node", command.getAttribute("node"));
                c.setAttribute("sessionid", command.getAttribute("sessionid"));
                c.setAttribute("action", action);

                String formType = responseElement == null ? null : responseElement.getAttribute("type");
                if (!action.equals("cancel") &&
                    responseElement != null &&
                    responseElement.getName().equals("x") &&
                    responseElement.getNamespace().equals("jabber:x:data") &&
                    formType != null && formType.equals("form")) {

                    responseElement.setAttribute("type", "submit");
                    Element rsm = responseElement.findChild("set", "http://jabber.org/protocol/rsm");
                    if (rsm != null) {
                        Element max = new Element("max", "http://jabber.org/protocol/rsm");
                        max.setContent("1000");
                        rsm.addChild(max);
                    }
                    c.addChild(responseElement);
                }

                xmppConnectionService.sendIqPacket(getAccount(), packet, (a, iq) -> {
                    getView().post(() -> {
                        updateWithResponse(iq);
                    });
                });

                loading();
                return false;
            }

            protected void loading() {
                loadingTimer.schedule(new TimerTask() {
                    @Override
                    public void run() {
                        getView().post(() -> {
                            loading = true;
                            notifyDataSetChanged();
                        });
                    }
                }, 500);
            }

            protected GridLayoutManager setupLayoutManager() {
                layoutManager = new GridLayoutManager(mPager.getContext(), layoutManager == null ? 1 : layoutManager.getSpanCount());
                layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
                    @Override
                    public int getSpanSize(int position) {
                        if (getItemViewType(position) != TYPE_RESULT_CELL) return layoutManager.getSpanCount();
                        return 1;
                    }
                });
                return layoutManager;
            }

            public void setBinding(CommandPageBinding b) {
                mBinding = b;
                // https://stackoverflow.com/a/32350474/8611
                mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
                    @Override
                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
                        if(rv.getChildCount() > 0) {
                            int[] location = new int[2];
                            rv.getLocationOnScreen(location);
                            View childView = rv.findChildViewUnder(e.getX(), e.getY());
                            if (childView instanceof ViewGroup) {
                                childView = findViewAt((ViewGroup) childView, location[0] + e.getX(), location[1] + e.getY());
                            }
                            if (childView instanceof ListView || childView instanceof WebView) {
                                int action = e.getAction();
                                switch (action) {
                                    case MotionEvent.ACTION_DOWN:
                                        rv.requestDisallowInterceptTouchEvent(true);
                                }
                            }
                        }

                        return false;
                    }

                    @Override
                    public void onRequestDisallowInterceptTouchEvent(boolean disallow) { }

                    @Override
                    public void onTouchEvent(RecyclerView rv, MotionEvent e) { }
                });
                mBinding.form.setLayoutManager(setupLayoutManager());
                mBinding.form.setAdapter(this);
                mBinding.actions.setAdapter(actionsAdapter);
                mBinding.actions.setOnItemClickListener((parent, v, pos, id) -> {
                    if (execute(pos)) {
                        removeSession(CommandSession.this);
                    }
                });

                actionsAdapter.notifyDataSetChanged();
            }

            // https://stackoverflow.com/a/36037991/8611
            private View findViewAt(ViewGroup viewGroup, float x, float y) {
                for(int i = 0; i < viewGroup.getChildCount(); i++) {
                    View child = viewGroup.getChildAt(i);
                    if (child instanceof ViewGroup && !(child instanceof ListView) && !(child instanceof WebView)) {
                        View foundView = findViewAt((ViewGroup) child, x, y);
                        if (foundView != null && foundView.isShown()) {
                            return foundView;
                        }
                    } else {
                        int[] location = new int[2];
                        child.getLocationOnScreen(location);
                        Rect rect = new Rect(location[0], location[1], location[0] + child.getWidth(), location[1] + child.getHeight());
                        if (rect.contains((int)x, (int)y)) {
                            return child;
                        }
                    }
                }

                return null;
            }
        }
    }
}

M src/main/java/eu/siacs/conversations/entities/IndividualMessage.java => src/main/java/eu/siacs/conversations/entities/IndividualMessage.java +1 -1
@@ 44,7 44,7 @@ public class IndividualMessage extends Message {
	}

	private IndividualMessage(Conversational conversation, String uuid, String conversationUUid, Jid counterpart, Jid trueCounterpart, String body, long timeSent, int encryption, int status, int type, boolean carbon, String remoteMsgId, String relativeFilePath, String serverMsgId, String fingerprint, boolean read, String edited, boolean oob, String errorMessage, Set<ReadByMarker> readByMarkers, boolean markable, boolean deleted, String bodyLanguage) {
		super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, edited, oob, errorMessage, readByMarkers, markable, deleted, bodyLanguage, null, null, null);
		super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, edited, oob, errorMessage, readByMarkers, markable, deleted, bodyLanguage, null, null, null, null);
	}

	@Override

M src/main/java/eu/siacs/conversations/entities/Message.java => src/main/java/eu/siacs/conversations/entities/Message.java +43 -4
@@ 6,6 6,7 @@ import android.graphics.Color;
import android.text.SpannableStringBuilder;
import android.util.Log;

import com.google.common.io.ByteSource;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.primitives.Longs;


@@ 13,6 14,7 @@ import com.google.common.primitives.Longs;
import org.json.JSONException;

import java.lang.ref.WeakReference;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;


@@ 20,6 22,7 @@ import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.concurrent.CopyOnWriteArraySet;

import eu.siacs.conversations.Config;


@@ 35,6 38,9 @@ import eu.siacs.conversations.utils.MessageUtils;
import eu.siacs.conversations.utils.MimeUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Tag;
import eu.siacs.conversations.xml.XmlReader;

public class Message extends AbstractEntity implements AvatarService.Avatarable {



@@ 107,6 113,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
    protected boolean carbon = false;
    private boolean oob = false;
    protected URI oobUri = null;
    protected List<Element> payloads = new ArrayList<>();
    protected List<Edit> edits = new ArrayList<>();
    protected String relativeFilePath;
    protected boolean read = true;


@@ 161,6 168,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
                null,
                null,
                null,
                null,
                null);
    }



@@ 189,6 197,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
                null,
                null,
                null,
                null,
                null);
    }



@@ 198,7 207,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
                      final String remoteMsgId, final String relativeFilePath,
                      final String serverMsgId, final String fingerprint, final boolean read,
                      final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
                      final boolean markable, final boolean deleted, final String bodyLanguage, final String subject, final String oobUri, final String fileParams) {
                      final boolean markable, final boolean deleted, final String bodyLanguage, final String subject, final String oobUri, final String fileParams, final List<Element> payloads) {
        this.conversation = conversation;
        this.uuid = uuid;
        this.conversationUuid = conversationUUid;


@@ 225,9 234,21 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
        this.bodyLanguage = bodyLanguage;
        this.subject = subject;
        if (fileParams != null) this.fileParams = new FileParams(fileParams);
    }
        if (payloads != null) this.payloads = payloads;
    }

    public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
        String payloadsStr = cursor.getString(cursor.getColumnIndex("payloads"));
        List<Element> payloads = new ArrayList<>();
        if (payloadsStr != null) {
            final XmlReader xmlReader = new XmlReader();
            xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream());
            Tag tag;
            while ((tag = xmlReader.readTag()) != null) {
                payloads.add(xmlReader.readElement(tag));
            }
        }

    public static Message fromCursor(Cursor cursor, Conversation conversation) {
        return new Message(conversation,
                cursor.getString(cursor.getColumnIndex(UUID)),
                cursor.getString(cursor.getColumnIndex(CONVERSATION)),


@@ 253,7 274,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
                cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)),
                cursor.getString(cursor.getColumnIndex("subject")),
                cursor.getString(cursor.getColumnIndex("oobUri")),
                cursor.getString(cursor.getColumnIndex("fileParams"))
                cursor.getString(cursor.getColumnIndex("fileParams")),
                payloads
        );
    }



@@ 289,6 311,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
        values.put("subject", subject);
        values.put("oobUri", oobUri == null ? null : oobUri.toString());
        values.put("fileParams", fileParams == null ? null : fileParams.toString());
        values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
        return values;
    }



@@ 841,6 864,22 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
        this.oob = this.oobUri != null;
    }

    public void addPayload(Element el) {
        this.payloads.add(el);
    }

    public List<Element> getCommands() {
        if (this.payloads == null) return null;

        for (Element el : this.payloads) {
            if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
                return el.getChildren();
            }
        }

        return null;
    }

    public String getMimeType() {
        String extension;
        if (relativeFilePath != null) {

M src/main/java/eu/siacs/conversations/entities/Presences.java => src/main/java/eu/siacs/conversations/entities/Presences.java +13 -0
@@ 149,6 149,19 @@ public class Presences {
        return false;
    }

    public String firstWhichSupport(final String namespace) {
        for (Map.Entry<String, Presence> entry : this.presences.entrySet()) {
            String resource = entry.getKey();
            Presence presence = entry.getValue();
            ServiceDiscoveryResult disco = presence.getServiceDiscoveryResult();
            if (disco != null && disco.getFeatures().contains(namespace)) {
                return resource;
            }
        }

        return null;
    }

    public boolean anyIdentity(final String category, final String type) {
        synchronized (this.presences) {
            if (this.presences.size() == 0) {

M src/main/java/eu/siacs/conversations/generator/IqGenerator.java => src/main/java/eu/siacs/conversations/generator/IqGenerator.java +8 -1
@@ 552,7 552,14 @@ public class IqGenerator extends AbstractGenerator {
    public IqPacket queryDiscoItems(Jid jid) {
        IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
        packet.setTo(jid);
        packet.addChild("query",Namespace.DISCO_ITEMS);
        packet.query(Namespace.DISCO_ITEMS);
        return packet;
    }

    public IqPacket queryDiscoItems(Jid jid, String node) {
        IqPacket packet = queryDiscoItems(jid);
        final Element query = packet.query(Namespace.DISCO_ITEMS);
        query.setAttribute("node", node);
        return packet;
    }


M src/main/java/eu/siacs/conversations/parser/MessageParser.java => src/main/java/eu/siacs/conversations/parser/MessageParser.java +5 -0
@@ 585,6 585,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                }
            }
            message.markable = packet.hasChild("markable", "urn:xmpp:chat-markers:0");
            for (Element el : packet.getChildren()) {
                if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
                    message.addPayload(el);
                }
            }
            if (conversationMultiMode) {
                message.setMucUser(conversation.getMucOptions().findUserByFullJid(counterpart));
                final Jid fallback = conversation.getMucOptions().getTrueCounterpart(counterpart);

M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java => src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +8 -0
@@ 244,6 244,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                db.execSQL("PRAGMA cheogram.user_version = 2");
            }

            if(cheogramVersion < 3) {
                db.execSQL(
                    "ALTER TABLE cheogram." + Message.TABLENAME + " " +
                    "ADD COLUMN payloads TEXT"
                );
                db.execSQL("PRAGMA cheogram.user_version = 3");
            }

            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();

M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java => src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +25 -7
@@ 4673,6 4673,7 @@ public class XmppConnectionService extends Service {
        if (result != null) {
            return result;
        } else {
            if (key.first == null || key.second == null) return null;
            result = databaseBackend.findDiscoveryResult(key.first, key.second);
            if (result != null) {
                discoCache.put(key, result);


@@ 4700,6 4701,10 @@ public class XmppConnectionService extends Service {
    }

    public void fetchCaps(Account account, final Jid jid, final Presence presence) {
        fetchCaps(account, jid, presence, null);
    }

    public void fetchCaps(Account account, final Jid jid, final Presence presence, Runnable cb) {
        final Pair<String, String> key = new Pair<>(presence.getHash(), presence.getVer());
        final ServiceDiscoveryResult disco = getCachedServiceDiscoveryResult(key);



@@ 4726,14 4731,15 @@ public class XmppConnectionService extends Service {
            sendIqPacket(account, request, (a, response) -> {
                if (response.getType() == IqPacket.TYPE.RESULT) {
                    final ServiceDiscoveryResult discoveryResult = new ServiceDiscoveryResult(response);
                    if (presence.getVer().equals(discoveryResult.getVer())) {
                    if (presence.getVer() == null || presence.getVer().equals(discoveryResult.getVer())) {
                        databaseBackend.insertDiscoveryResult(discoveryResult);
                        injectServiceDiscoveryResult(a.getRoster(), presence.getHash(), presence.getVer(), discoveryResult);
                        injectServiceDiscoveryResult(a.getRoster(), presence.getHash(), presence.getVer(), jid.getResource(), discoveryResult);
                        if (discoveryResult.hasIdentity("gateway", "pstn")) {
                            final Contact contact = account.getRoster().getContact(jid);
                            contact.registerAsPhoneAccount(this);
                            mQuickConversationsService.considerSyncBackground(false);
                        }
                        if (cb != null) cb.run();
                    } else {
                        Log.d(Config.LOGTAG, a.getJid().asBareJid() + ": mismatch in caps for contact " + jid + " " + presence.getVer() + " vs " + discoveryResult.getVer());
                    }


@@ 4744,14 4750,26 @@ public class XmppConnectionService extends Service {
        }
    }

    private void injectServiceDiscoveryResult(Roster roster, String hash, String ver, ServiceDiscoveryResult disco) {
    public void fetchCommands(Account account, final Jid jid, OnIqPacketReceived callback) {
        final IqPacket request = mIqGenerator.queryDiscoItems(jid, "http://jabber.org/protocol/commands");
        sendIqPacket(account, request, callback);
    }

    private void injectServiceDiscoveryResult(Roster roster, String hash, String ver, String resource, ServiceDiscoveryResult disco) {
        boolean rosterNeedsSync = false;
        for (final Contact contact : roster.getContacts()) {
            boolean serviceDiscoverySet = false;
            for (final Presence presence : contact.getPresences().getPresences()) {
                if (hash.equals(presence.getHash()) && ver.equals(presence.getVer())) {
                    presence.setServiceDiscoveryResult(disco);
                    serviceDiscoverySet = true;
            Presence onePresence = contact.getPresences().get(resource == null ? "" : resource);
            if (onePresence != null) {
                onePresence.setServiceDiscoveryResult(disco);
                serviceDiscoverySet = true;
            }
            if (hash != null && ver != null) {
                for (final Presence presence : contact.getPresences().getPresences()) {
                    if (hash.equals(presence.getHash()) && ver.equals(presence.getVer())) {
                        presence.setServiceDiscoveryResult(disco);
                        serviceDiscoverySet = true;
                    }
                }
            }
            if (serviceDiscoverySet) {

M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java => src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +62 -0
@@ 55,11 55,14 @@ import android.widget.Toast;

import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.databinding.DataBindingUtil;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;

import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;


@@ 73,6 76,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;


@@ 92,6 96,7 @@ import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.MucOptions.User;
import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.Presences;
import eu.siacs.conversations.entities.ReadByMarker;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.entities.TransferablePlaceholder;


@@ 100,6 105,7 @@ import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.MessageArchiveService;
import eu.siacs.conversations.services.QuickConversationsService;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.adapter.CommandAdapter;
import eu.siacs.conversations.ui.adapter.MediaPreviewAdapter;
import eu.siacs.conversations.ui.adapter.MessageAdapter;
import eu.siacs.conversations.ui.util.ActivityResult;


@@ 129,6 135,7 @@ import eu.siacs.conversations.utils.QuickLoader;
import eu.siacs.conversations.utils.StylingHelper;
import eu.siacs.conversations.utils.TimeFrameUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.XmppConnection;


@@ 139,6 146,7 @@ import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
import eu.siacs.conversations.xmpp.jingle.RtpCapability;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;

public class ConversationFragment extends XmppFragment
        implements EditMessage.KeyboardListener,


@@ 185,6 193,7 @@ public class ConversationFragment extends XmppFragment
    private final PendingItem<Message> pendingMessage = new PendingItem<>();
    public Uri mPendingEditorContent = null;
    protected MessageAdapter messageListAdapter;
    protected CommandAdapter commandAdapter;
    private MediaPreviewAdapter mediaPreviewAdapter;
    private String lastMessageUuid = null;
    private Conversation conversation;


@@ 1518,6 1527,9 @@ public class ConversationFragment extends XmppFragment
            case R.id.action_toggle_pinned:
                togglePinned();
                break;
            case R.id.action_refresh_feature_discovery:
                refreshFeatureDiscovery();
                break;
            default:
                break;
        }


@@ 1557,6 1569,17 @@ public class ConversationFragment extends XmppFragment
        }
    }

    private void refreshFeatureDiscovery() {
        for (Map.Entry<String, Presence> entry : conversation.getContact().getPresences().getPresencesMap().entrySet()) {
            Jid jid = conversation.getContact().getJid();
            if (!entry.getKey().equals("")) jid = jid.withResource(entry.getKey());
            activity.xmppConnectionService.fetchCaps(conversation.getAccount(), jid, entry.getValue(), () -> {
                if (activity == null) return;
                activity.runOnUiThread(() -> { refresh(); });
            });
        }
    }

    private void togglePinned() {
        final boolean pinned =
                conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false);


@@ 2493,9 2516,47 @@ public class ConversationFragment extends XmppFragment
        activity.xmppConnectionService
                .getNotificationService()
                .setOpenConversation(this.conversation);

        if (commandAdapter == null) {
            conversation.setupViewPager(binding.conversationViewPager, binding.tabLayout);
            commandAdapter = new CommandAdapter((XmppActivity) getActivity());
            binding.commandsView.setAdapter(commandAdapter);
            binding.commandsView.setOnItemClickListener((parent, view, position, id) -> {
                conversation.startCommand(commandAdapter.getItem(position), activity.xmppConnectionService);
            });
            refreshCommands();
        }

        return true;
    }

    protected void refreshCommands() {
        if (commandAdapter == null) return;

        Jid commandJid = conversation.getContact().resourceWhichSupport(Namespace.COMMANDS);
        if (commandJid == null) {
            conversation.hideViewPager();
        } else {
            conversation.showViewPager();
            activity.xmppConnectionService.fetchCommands(conversation.getAccount(), commandJid, (a, iq) -> {
                if (activity == null) return;

                activity.runOnUiThread(() -> {
                    if (iq.getType() == IqPacket.TYPE.RESULT) {
                        binding.commandsViewProgressbar.setVisibility(View.GONE);
                        commandAdapter.clear();
                        for (Element child : iq.query().getChildren()) {
                            if (!"item".equals(child.getName()) || !Namespace.DISCO_ITEMS.equals(child.getNamespace())) continue;
                            commandAdapter.add(child);
                        }
                    }

                    if (commandAdapter.getCount() < 1) conversation.hideViewPager();
                });
            });
        }
    }

    private void resetUnreadMessagesCount() {
        lastMessageUuid = null;
        hideUnreadMessagesCount();


@@ 2794,6 2855,7 @@ public class ConversationFragment extends XmppFragment
                }
                updateSendButton();
                updateEditablity();
                refreshCommands();
            }
        }
    }

A src/main/java/eu/siacs/conversations/ui/adapter/CommandAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/CommandAdapter.java +27 -0
@@ 0,0 1,27 @@
package eu.siacs.conversations.ui.adapter;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;

import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;

import eu.siacs.conversations.R;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.ui.XmppActivity;
import eu.siacs.conversations.databinding.CommandRowBinding;

public class CommandAdapter extends ArrayAdapter<Element> {
	public CommandAdapter(XmppActivity activity) {
		super(activity, 0);
	}

	@Override
	public View getView(int position, View view, @NonNull ViewGroup parent) {
		CommandRowBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.command_row, parent, false);
		binding.command.setText(getItem(position).getAttribute("name"));
		return binding.getRoot();
	}
}

A src/main/java/eu/siacs/conversations/ui/adapter/CommandButtonAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/CommandButtonAdapter.java +29 -0
@@ 0,0 1,29 @@
package eu.siacs.conversations.ui.adapter;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;

import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;

import eu.siacs.conversations.R;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.ui.XmppActivity;
import eu.siacs.conversations.databinding.CommandButtonBinding;

public class CommandButtonAdapter extends ArrayAdapter<Element> {
	public CommandButtonAdapter(XmppActivity activity) {
		super(activity, 0);
	}

	@Override
	public View getView(int position, View view, @NonNull ViewGroup parent) {
		CommandButtonBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.command_button, parent, false);
		binding.command.setText(getItem(position).getAttribute("name"));
		binding.command.setFocusable(false);
		binding.command.setClickable(false);
		return binding.getRoot();
	}
}

M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +15 -0
@@ 24,6 24,7 @@ import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;


@@ 75,6 76,7 @@ import eu.siacs.conversations.utils.TimeFrameUtils;
import eu.siacs.conversations.utils.UIHelper;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.mam.MamReference;
import eu.siacs.conversations.xml.Element;

public class MessageAdapter extends ArrayAdapter<Message> {



@@ 616,6 618,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
        final boolean isInValidSession = message.isValidInSession() && (!omemoEncryption || message.isTrusted());
        final Conversational conversation = message.getConversation();
        final Account account = conversation.getAccount();
        final List<Element> commands = message.getCommands();
        final int type = getItemViewType(position);
        ViewHolder viewHolder;
        if (view == null) {


@@ 661,6 664,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                    viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
                    viewHolder.encryption = view.findViewById(R.id.message_encryption);
                    viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
                    viewHolder.commands_list = view.findViewById(R.id.commands_list);
                    break;
                case STATUS:
                    view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);


@@ 830,6 834,16 @@ public class MessageAdapter extends ArrayAdapter<Message> {
        }

        if (type == RECEIVED) {
            if (commands != null && conversation instanceof Conversation) {
                CommandButtonAdapter adapter = new CommandButtonAdapter(activity);
                adapter.addAll(commands);
                viewHolder.commands_list.setAdapter(adapter);
                viewHolder.commands_list.setVisibility(View.VISIBLE);
                viewHolder.commands_list.setOnItemClickListener((p, v, pos, id) -> {
                    ((Conversation) conversation).startCommand(adapter.getItem(pos), activity.xmppConnectionService);
                });
            }

            if (isInValidSession) {
                int bubble;
                if (!mUseGreenBackground) {


@@ 938,5 952,6 @@ public class MessageAdapter extends ArrayAdapter<Message> {
        protected ImageView contact_picture;
        protected TextView status_message;
        protected TextView encryption;
        protected ListView commands_list;
    }
}

A src/main/res/layout/command_row.xml => src/main/res/layout/command_row.xml +36 -0
@@ 0,0 1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?selectableItemBackground"
        android:paddingLeft="8dp"
        android:paddingBottom="8dp"
        android:paddingTop="8dp">

        <LinearLayout
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:orientation="vertical"
            android:paddingLeft="@dimen/avatar_item_distance">

            <TextView
                android:id="@+id/command"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scrollHorizontally="false"
                android:singleLine="true"
                android:textAppearance="@style/TextAppearance.Conversations.Subhead" />

            <TextView
                android:id="@+id/description"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textAppearance="@style/TextAppearance.Conversations.Body2" />
        </LinearLayout>

    </RelativeLayout>
</layout>

M src/main/res/layout/fragment_conversation.xml => src/main/res/layout/fragment_conversation.xml +200 -148
@@ 7,164 7,216 @@
        android:layout_height="match_parent"
        android:background="?attr/color_background_secondary">

        <ListView
            android:id="@+id/messages_view"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_above="@+id/snackbar"
            android:layout_alignParentStart="true"
            android:layout_alignParentLeft="true"
            android:layout_alignParentTop="true"
            android:background="?attr/color_background_secondary"
            android:divider="@null"
            android:dividerHeight="0dp"
            android:listSelector="@android:color/transparent"
            android:stackFromBottom="true"
            android:transcriptMode="normal"
            tools:listitem="@layout/message_sent"></ListView>

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/scroll_to_bottom_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignBottom="@+id/messages_view"
            android:layout_alignParentEnd="true"
            android:layout_alignParentRight="true"
            android:alpha="0.85"
            android:src="?attr/icon_scroll_down"
        <com.google.android.material.tabs.TabLayout
            android:visibility="gone"
            app:backgroundTint="?attr/color_background_primary"
            app:fabSize="mini"
            app:useCompatPadding="true" />

        <eu.siacs.conversations.ui.widget.UnreadCountCustomView
            android:id="@+id/unread_count_custom_view"
            android:layout_width="?attr/IconSize"
            android:layout_height="?attr/IconSize"
            android:layout_alignTop="@+id/scroll_to_bottom_button"
            android:layout_alignEnd="@+id/scroll_to_bottom_button"
            android:layout_alignRight="@+id/scroll_to_bottom_button"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="8dp"
            android:layout_marginRight="8dp"
            android:elevation="8dp"
            android:visibility="gone"
            app:backgroundColor="?attr/unread_count"
            tools:ignore="RtlCompat" />

        <RelativeLayout
            android:id="@+id/textsend"
            android:layout_width="fill_parent"
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true"
            android:layout_alignParentLeft="true"
            android:layout_alignParentBottom="true"
            android:background="?attr/colorPrimary"
            android:elevation="@dimen/toolbar_elevation"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:tabGravity="fill"
            app:tabIndicatorColor="@color/white87"
            app:tabMode="scrollable"
            app:tabSelectedTextColor="@color/white"
            app:tabTextColor="@color/white70" />

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/conversation_view_pager"
            android:layout_below="@id/tab_layout"
            android:layout_width="fill_parent"
            android:layout_height="fill_parent"
            android:background="?attr/color_background_primary">

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentStart="true"
                android:layout_alignParentLeft="true"
                android:layout_toStartOf="@+id/textSendButton"
                android:layout_toLeftOf="@+id/textSendButton"
                android:orientation="vertical">

                <TextView
                    android:id="@+id/text_input_hint"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="8dp"
                    android:maxLines="1"
                    android:paddingLeft="8dp"
                    android:paddingRight="8dp"
                    android:textAppearance="@style/TextAppearance.Conversations.Caption.Highlight"
                    android:visibility="gone" />
            <RelativeLayout
                android:layout_width="fill_parent"
                android:layout_height="fill_parent">

                <androidx.recyclerview.widget.RecyclerView
                    android:id="@+id/media_preview"
                <ListView
                    android:id="@+id/messages_view"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:layout_above="@+id/snackbar"
                    android:layout_alignParentStart="true"
                    android:layout_alignParentLeft="true"
                    android:layout_alignParentTop="true"
                    android:background="?attr/color_background_secondary"
                    android:divider="@null"
                    android:dividerHeight="0dp"
                    android:listSelector="@android:color/transparent"
                    android:stackFromBottom="true"
                    android:transcriptMode="normal"
                    tools:listitem="@layout/message_sent"></ListView>

                <RelativeLayout
                    android:id="@+id/textsend"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:layout_alignParentStart="true"
                    android:layout_alignParentLeft="true"
                    android:layout_alignParentBottom="true"
                    android:background="?attr/color_background_primary">

                    <LinearLayout
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_alignParentStart="true"
                        android:layout_alignParentLeft="true"
                        android:layout_toStartOf="@+id/textSendButton"
                        android:layout_toLeftOf="@+id/textSendButton"
                        android:orientation="vertical">
    
                        <TextView
                            android:id="@+id/text_input_hint"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:layout_marginTop="8dp"
                            android:maxLines="1"
                            android:paddingLeft="8dp"
                            android:paddingRight="8dp"
                            android:textAppearance="@style/TextAppearance.Conversations.Caption.Highlight"
                            android:visibility="gone" />

                        <androidx.recyclerview.widget.RecyclerView
                            android:id="@+id/media_preview"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:orientation="horizontal"
                            android:paddingTop="8dp"
                            android:requiresFadingEdge="horizontal"
                            android:visibility="gone"
                            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                            tools:listitem="@layout/media_preview">

                        </androidx.recyclerview.widget.RecyclerView>

                        <eu.siacs.conversations.ui.widget.EditMessage
                            android:id="@+id/textinput"
                            style="@style/Widget.Conversations.EditText"
                            android:layout_width="match_parent"
                            android:layout_height="wrap_content"
                            android:background="?attr/color_background_primary"
                            android:ems="10"
                            android:imeOptions="flagNoExtractUi|actionSend"
                            android:inputType="textShortMessage|textMultiLine|textCapSentences"
                            android:maxLines="8"
                            android:minHeight="48dp"
                            android:minLines="1"
                            android:padding="8dp">

                            <requestFocus />
                        </eu.siacs.conversations.ui.widget.EditMessage>

                    </LinearLayout>

                    <ImageButton
                        android:id="@+id/textSendButton"
                        android:layout_width="48dp"
                        android:layout_height="48dp"
                        android:layout_alignParentEnd="true"
                        android:layout_alignParentRight="true"
                        android:layout_centerVertical="true"
                        android:background="?attr/color_background_primary"
                        android:contentDescription="@string/send_message"
                        android:src="?attr/ic_send_text_offline" />
                </RelativeLayout>
                <com.google.android.material.floatingactionbutton.FloatingActionButton
                    android:id="@+id/scroll_to_bottom_button"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:orientation="horizontal"
                    android:paddingTop="8dp"
                    android:requiresFadingEdge="horizontal"
                    android:layout_alignBottom="@+id/messages_view"
                    android:layout_alignParentEnd="true"
                    android:layout_alignParentRight="true"
                    android:alpha="0.85"
                    android:src="?attr/icon_scroll_down"
                    android:visibility="gone"
                    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                    tools:listitem="@layout/media_preview">

                </androidx.recyclerview.widget.RecyclerView>
                    app:backgroundTint="?attr/color_background_primary"
                    app:fabSize="mini"
                    app:useCompatPadding="true" />

                <eu.siacs.conversations.ui.widget.UnreadCountCustomView
                    android:id="@+id/unread_count_custom_view"
                    android:layout_width="?attr/IconSize"
                    android:layout_height="?attr/IconSize"
                    android:layout_alignTop="@+id/scroll_to_bottom_button"
                    android:layout_alignEnd="@+id/scroll_to_bottom_button"
                    android:layout_alignRight="@+id/scroll_to_bottom_button"
                    android:layout_marginTop="16dp"
                    android:layout_marginEnd="8dp"
                    android:layout_marginRight="8dp"
                    android:elevation="8dp"
                    android:visibility="gone"
                    app:backgroundColor="?attr/unread_count"
                    tools:ignore="RtlCompat" />

                <eu.siacs.conversations.ui.widget.EditMessage
                    android:id="@+id/textinput"
                    style="@style/Widget.Conversations.EditText"
                    android:layout_width="match_parent"
                <RelativeLayout
                    android:id="@+id/snackbar"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:background="?attr/color_background_primary"
                    android:ems="10"
                    android:imeOptions="flagNoExtractUi|actionSend"
                    android:inputType="textShortMessage|textMultiLine|textCapSentences"
                    android:maxLines="8"
                    android:layout_above="@+id/textsend"
                    android:layout_marginLeft="8dp"
                    android:layout_marginRight="8dp"
                    android:layout_marginBottom="4dp"
                    android:background="@drawable/snackbar"
                    android:minHeight="48dp"
                    android:minLines="1"
                    android:padding="8dp">

                    <requestFocus />
                </eu.siacs.conversations.ui.widget.EditMessage>

            </LinearLayout>

            <ImageButton
                android:id="@+id/textSendButton"
                android:layout_width="48dp"
                android:layout_height="48dp"
                android:layout_alignParentEnd="true"
                android:layout_alignParentRight="true"
                android:layout_centerVertical="true"
                android:background="?attr/color_background_primary"
                android:contentDescription="@string/send_message"
                android:src="?attr/ic_send_text_offline" />
        </RelativeLayout>

        <RelativeLayout
            android:id="@+id/snackbar"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_above="@+id/textsend"
            android:layout_marginLeft="8dp"
            android:layout_marginRight="8dp"
            android:layout_marginBottom="4dp"
            android:background="@drawable/snackbar"
            android:minHeight="48dp"
            android:visibility="gone">

            <TextView
                android:id="@+id/snackbar_message"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentStart="true"
                android:layout_alignParentLeft="true"
                android:layout_centerVertical="true"
                android:layout_toStartOf="@+id/snackbar_action"
                android:layout_toLeftOf="@+id/snackbar_action"
                android:paddingStart="24dp"
                android:paddingLeft="24dp"
                android:textAppearance="@style/TextAppearance.Conversations.Body1.OnDark" />

            <TextView
                android:id="@+id/snackbar_action"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentEnd="true"
                android:layout_alignParentRight="true"
                android:layout_centerVertical="true"
                android:paddingLeft="24dp"
                android:paddingTop="16dp"
                android:paddingRight="24dp"
                android:paddingBottom="16dp"
                android:textAllCaps="true"
                android:textAppearance="@style/TextAppearance.Conversations.Body1.OnDark"
                android:textStyle="bold" />
        </RelativeLayout>
                    android:visibility="gone">

                    <TextView
                        android:id="@+id/snackbar_message"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_alignParentStart="true"
                        android:layout_alignParentLeft="true"
                        android:layout_centerVertical="true"
                        android:layout_toStartOf="@+id/snackbar_action"
                        android:layout_toLeftOf="@+id/snackbar_action"
                        android:paddingStart="24dp"
                        android:paddingLeft="24dp"
                        android:textAppearance="@style/TextAppearance.Conversations.Body1.OnDark" />

                    <TextView
                        android:id="@+id/snackbar_action"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_alignParentEnd="true"
                        android:layout_alignParentRight="true"
                        android:layout_centerVertical="true"
                        android:paddingLeft="24dp"
                        android:paddingTop="16dp"
                        android:paddingRight="24dp"
                        android:paddingBottom="16dp"
                        android:textAllCaps="true"
                        android:textAppearance="@style/TextAppearance.Conversations.Body1.OnDark"
                        android:textStyle="bold" />
                </RelativeLayout>
		        </RelativeLayout>

            <RelativeLayout
                android:layout_width="fill_parent"
                android:layout_height="fill_parent">

                <ListView
                    android:id="@+id/commands_view"
                    android:layout_width="fill_parent"
                    android:layout_height="wrap_content"
                    android:layout_alignParentStart="true"
                    android:layout_alignParentLeft="true"
                    android:layout_alignParentTop="true"
                    android:background="?attr/color_background_secondary"
                    android:divider="@android:color/transparent"
                    android:dividerHeight="0dp"></ListView>

                <ProgressBar
                    android:id="@+id/commands_view_progressbar"
                    android:layout_width="match_parent"
                    android:layout_height="130dp"
                    android:paddingLeft="8dp"
                    android:paddingRight="8dp"
                    android:paddingBottom="16dp" />

            </RelativeLayout>

        </androidx.viewpager.widget.ViewPager>

    </RelativeLayout>
</layout>
\ No newline at end of file
</layout>

M src/main/res/layout/message_content.xml => src/main/res/layout/message_content.xml +9 -1
@@ 28,6 28,14 @@
        android:longClickable="true"
        android:visibility="gone"/>

    <ListView
        android:id="@+id/commands_list"
        android:visibility="gone"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:divider="@android:color/transparent"
        android:dividerHeight="0dp"></ListView>

    <RelativeLayout
        android:id="@+id/audio_player"
        android:layout_width="@dimen/audio_player_width"


@@ 63,4 71,4 @@
            android:progress="100"/>
    </RelativeLayout>

</merge>
\ No newline at end of file
</merge>

M src/main/res/layout/simple_list_item.xml => src/main/res/layout/simple_list_item.xml +2 -1
@@ 22,4 22,5 @@
    android:paddingLeft="8dp"
    android:paddingRight="8dp"
    android:textAppearance="@style/TextAppearance.Conversations.Body1"
    android:textColor="?attr/edit_text_color" />
    android:textColor="?attr/edit_text_color"
    android:background="@drawable/list_choice" />

M src/main/res/menu/fragment_conversation.xml => src/main/res/menu/fragment_conversation.xml +7 -1
@@ 135,8 135,14 @@
                android:orderInCategory="73"
                android:title="@string/add_to_favorites"
                app:showAsAction="never" />

            <item
                android:id="@+id/action_refresh_feature_discovery"
                android:orderInCategory="74"
                android:title="Refresh Feature Discovery"
                app:showAsAction="never" />
        </menu>
    </item>


</menu>
\ No newline at end of file
</menu>