List commands when available

Stephen Paul Weber created

If a JID advertises commands, list them.

This only works if the JID supports CAPS and is in our roster and we have
already fetched CAPS for the resource.

Change summary

src/main/java/eu/siacs/conversations/generator/IqGenerator.java          |   9 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |   5 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        |  79 
src/main/java/eu/siacs/conversations/ui/adapter/CommandAdapter.java      |  27 
src/main/res/layout/command_row.xml                                      |  36 
src/main/res/layout/fragment_conversation.xml                            | 251 
6 files changed, 300 insertions(+), 107 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -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;
     }
 

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -4744,6 +4744,11 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    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, ServiceDiscoveryResult disco) {
         boolean rosterNeedsSync = false;
         for (final Contact contact : roster.getContacts()) {

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -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;
@@ -1234,6 +1243,39 @@ public class ConversationFragment extends XmppFragment
                     new EditMessageActionModeCallback(this.binding.textinput));
         }
 
+        binding.conversationViewPager.setAdapter(new StaticPagerAdapter(
+            binding.conversationViewPager
+        ));
+        binding.tabLayout.setupWithViewPager(binding.conversationViewPager);
+
+        commandAdapter = new CommandAdapter((XmppActivity) getActivity());
+        binding.commandsView.setAdapter(commandAdapter);
+        Presences presences = conversation.getContact().getPresences();
+        for (Map.Entry<String, Presence> entry : presences.getPresencesMap().entrySet()) {
+            String resource = entry.getKey();
+            Presence presence = entry.getValue();
+            if (presence.getServiceDiscoveryResult().getFeatures().contains("http://jabber.org/protocol/commands")) {
+                binding.tabLayout.setVisibility(View.VISIBLE);
+                binding.conversationViewPager.setCurrentItem(1);
+                Jid jid = conversation.getContact().getJid();
+                if (resource != null && !resource.equals("")) jid = jid.withResource(resource);
+                activity.xmppConnectionService.fetchCommands(conversation.getAccount(), jid, (a, iq) -> {
+                    if (iq.getType() == IqPacket.TYPE.RESULT) {
+                        activity.runOnUiThread(() -> {
+                            for (Element child : iq.query().getChildren()) {
+                                if (!"item".equals(child.getName()) || !Namespace.DISCO_ITEMS.equals(child.getNamespace())) continue;
+                                commandAdapter.add(child);
+                            }
+                        });
+                    } else {
+                        binding.tabLayout.setVisibility(View.GONE);
+                        binding.conversationViewPager.setCurrentItem(0);
+                    }
+                });
+                break;
+            }
+        }
+
         return binding.getRoot();
     }
 
@@ -3604,4 +3646,41 @@ public class ConversationFragment extends XmppFragment
         }
         return activity;
     }
+
+    public class StaticPagerAdapter extends PagerAdapter {
+        ViewPager mPager;
+
+        StaticPagerAdapter(ViewPager pager) {
+            mPager = pager;
+        }
+
+        @NonNull
+        @Override
+        public View instantiateItem(@NonNull ViewGroup container, int position) {
+            return mPager.getChildAt(position);
+        }
+
+        @Override
+        public int getCount() {
+            return 2;
+        }
+
+        @Override
+        public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
+            return view == o;
+        }
+
+        @Nullable
+        @Override
+        public CharSequence getPageTitle(int position) {
+            switch (position) {
+                case 0:
+                    return "Conversation";
+                case 1:
+                    return "Commands";
+                default:
+                    return super.getPageTitle(position);
+            }
+        }
+    }
 }

src/main/java/eu/siacs/conversations/ui/adapter/CommandAdapter.java 🔗

@@ -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();
+	}
+}

src/main/res/layout/command_row.xml 🔗

@@ -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>

src/main/res/layout/fragment_conversation.xml 🔗

@@ -7,123 +7,162 @@
         android:layout_height="match_parent"
         android:background="?attr/color_background_secondary">
 
-        <ListView
-            android:id="@+id/messages_view"
-            android:layout_width="fill_parent"
+        <com.google.android.material.tabs.TabLayout
+            android:visibility="gone"
+            android:id="@+id/tab_layout"
+            android:layout_width="match_parent"
             android:layout_height="wrap_content"
+            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="fixed"
+            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_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"
-            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:layout_height="wrap_content"
-            android:layout_alignParentStart="true"
-            android:layout_alignParentLeft="true"
-            android:layout_alignParentBottom="true"
+            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">
+            <RelativeLayout
+                android:layout_width="fill_parent"
+                android:layout_height="fill_parent">
 
-                <TextView
-                    android:id="@+id/text_input_hint"
-                    android:layout_width="wrap_content"
+                <ListView
+                    android:id="@+id/messages_view"
+                    android:layout_width="fill_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_above="@+id/textsend"
+                    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_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_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">
+                    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>
 
-                </androidx.recyclerview.widget.RecyclerView>
+            <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>
 
-                <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>
+        </androidx.viewpager.widget.ViewPager>
 
         <RelativeLayout
             android:id="@+id/snackbar"
@@ -167,4 +206,4 @@
         </RelativeLayout>
 
     </RelativeLayout>
-</layout>
+</layout>