implement channel discovery over jabber.search.network

Daniel Gultsch created

Change summary

src/conversations/res/layout/activity_channel_discovery.xml                     |  33 
src/conversations/res/layout/search_result_item.xml                             |  48 
src/main/AndroidManifest.xml                                                    |   1 
src/main/java/eu/siacs/conversations/Config.java                                |   3 
src/main/java/eu/siacs/conversations/entities/ChannelSearchResult.java          |  35 
src/main/java/eu/siacs/conversations/services/AvatarService.java                |   7 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java        |  55 
src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java           | 165 
src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java          |  13 
src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java |  78 
src/main/java/eu/siacs/conversations/utils/AccountUtils.java                    |  16 
src/main/res/menu/channel_discovery_activity.xml                                |  11 
src/main/res/menu/start_conversation_fab_submenu.xml                            |   4 
src/main/res/values/strings.xml                                                 |   4 
14 files changed, 464 insertions(+), 9 deletions(-)

Detailed changes

src/conversations/res/layout/activity_channel_discovery.xml 🔗

@@ -0,0 +1,33 @@
+<?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="fill_parent"
+        android:background="?attr/color_background_primary"
+        android:orientation="vertical">
+
+        <include
+            android:id="@+id/toolbar"
+            layout="@layout/toolbar" />
+
+
+        <android.support.design.widget.CoordinatorLayout
+            android:id="@+id/coordinator"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="?attr/color_background_primary">
+
+            <android.support.v7.widget.RecyclerView
+                android:id="@+id/list"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:background="?attr/color_background_primary"
+                android:orientation="vertical"
+                app:layoutManager="android.support.v7.widget.LinearLayoutManager" />
+        </android.support.design.widget.CoordinatorLayout>
+
+    </LinearLayout>
+</layout>

src/conversations/res/layout/search_result_item.xml 🔗

@@ -0,0 +1,48 @@
+<?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:padding="@dimen/list_padding">
+
+        <com.makeramen.roundedimageview.RoundedImageView
+            android:id="@+id/avatar"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            android:layout_alignParentLeft="true"
+            android:scaleType="centerCrop"
+            app:riv_corner_radius="2dp"/>
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_toRightOf="@+id/avatar"
+            android:orientation="vertical"
+            android:layout_marginLeft="@dimen/avatar_item_distance">
+
+            <TextView
+                android:id="@+id/name"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                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:maxLines="2"
+                android:textAppearance="@style/TextAppearance.Conversations.Body1"/>
+            <TextView
+                android:id="@+id/room"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:maxLines="2"
+                android:textAppearance="@style/TextAppearance.Conversations.Body1.Secondary"/>
+        </LinearLayout>
+
+    </RelativeLayout>
+</layout>

src/main/AndroidManifest.xml 🔗

@@ -283,6 +283,7 @@
         <activity
             android:name=".ui.MucUsersActivity"
             android:label="@string/group_chat_members" />
+        <activity android:label="@string/discover_channels" android:name=".ui.ChannelDiscoveryActivity"/>
     </application>
 
 </manifest>

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -40,6 +40,9 @@ public final class Config {
     public static final String DOMAIN_LOCK = null; //only allow account creation for this domain
     public static final String MAGIC_CREATE_DOMAIN = "conversations.im";
     public static final String QUICKSY_DOMAIN = "quicksy.im";
+
+    public static final Jid CHANNEL_DISCOVERY = Jid.of("rodrigo.de.mucobedo@dreckshal.de");
+
     public static final boolean DISALLOW_REGISTRATION_IN_UI = false; //hide the register checkbox
 
     public static final boolean USE_RANDOM_RESOURCE_ON_EVERY_BIND = false;

src/main/java/eu/siacs/conversations/entities/ChannelSearchResult.java 🔗

@@ -0,0 +1,35 @@
+package eu.siacs.conversations.entities;
+
+import eu.siacs.conversations.services.AvatarService;
+import eu.siacs.conversations.utils.UIHelper;
+import rocks.xmpp.addr.Jid;
+
+public class ChannelSearchResult implements AvatarService.Avatarable {
+
+    private final String name;
+    private final String description;
+    private final Jid room;
+
+    public String getName() {
+        return name;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public Jid getRoom() {
+        return room;
+    }
+
+    public ChannelSearchResult(String name, String description, Jid room) {
+        this.name = name;
+        this.description = description;
+        this.room = room;
+    }
+
+    @Override
+    public int getAvatarBackgroundColor() {
+        return UIHelper.getColorForName(room != null ? room.asBareJid().toEscapedString() : getName());
+    }
+}

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

@@ -33,6 +33,7 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.entities.ChannelSearchResult;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
@@ -82,11 +83,17 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded {
 			return get((ListItem) avatarable, size, cachedOnly);
 		} else if (avatarable instanceof MucOptions.User) {
 			return get((MucOptions.User) avatarable, size, cachedOnly);
+		} else if (avatarable instanceof ChannelSearchResult) {
+			return get((ChannelSearchResult) avatarable, size, cachedOnly);
 		}
 		throw new AssertionError("AvatarService does not know how to generate avatar from "+avatarable.getClass().getName());
 
 	}
 
+	private Bitmap get(final ChannelSearchResult result, final int size, boolean cacheOnly) {
+		return get(result.getName(), result.getRoom().asBareJid().toEscapedString(), size, cacheOnly);
+	}
+
 	private Bitmap get(final Contact contact, final int size, boolean cachedOnly) {
 		if (contact.isSelf()) {
 			return get(contact.getAccount(), size, cachedOnly);

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

@@ -49,6 +49,7 @@ import org.openintents.openpgp.util.OpenPgpServiceConnection;
 
 import java.io.File;
 import java.net.URL;
+import java.nio.channels.Channel;
 import java.security.SecureRandom;
 import java.security.Security;
 import java.security.cert.CertificateException;
@@ -84,6 +85,7 @@ import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Blockable;
 import eu.siacs.conversations.entities.Bookmark;
+import eu.siacs.conversations.entities.ChannelSearchResult;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
@@ -112,6 +114,7 @@ import eu.siacs.conversations.ui.UiCallback;
 import eu.siacs.conversations.ui.interfaces.OnAvatarPublication;
 import eu.siacs.conversations.ui.interfaces.OnMediaLoaded;
 import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable;
+import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.Compatibility;
 import eu.siacs.conversations.utils.ConversationsFileObserver;
 import eu.siacs.conversations.utils.CryptoHelper;
@@ -795,6 +798,58 @@ public class XmppConnectionService extends Service {
         return pingNow;
     }
 
+    public void discoverChannels(String query, OnChannelSearchResultsFound listener) {
+        IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
+        packet.setTo(Config.CHANNEL_DISCOVERY);
+        Element search = packet.addChild("search","https://xmlns.zombofant.net/muclumbus/search/1.0");
+        search.addChild("set","http://jabber.org/protocol/rsm").addChild("max").setContent("100");
+        Bundle bundle = new Bundle();
+        if (!TextUtils.isEmpty(query)) {
+            bundle.putString("q",query);
+        }
+        Data data = Data.create("https://xmlns.zombofant.net/muclumbus/search/1.0#params", bundle);
+        search.addChild(data);
+        final Account account = AccountUtils.getFirstEnabled(this);
+        if (account == null) {
+            return;
+        }
+        sendIqPacket(account, packet, new OnIqPacketReceived() {
+            @Override
+            public void onIqPacketReceived(Account account, IqPacket response) {
+                ArrayList<ChannelSearchResult> searchResults = new ArrayList<>();
+                if (response.getType() == IqPacket.TYPE.RESULT) {
+                    Element result = response.findChild("result","https://xmlns.zombofant.net/muclumbus/search/1.0");
+                    if (result != null) {
+                        for(Element child : result.getChildren()) {
+                            if ("item".equals(child.getName())) {
+                                String name = child.findChildContent("name");
+                                String description = child.findChildContent("description");
+                                Jid room = child.getAttributeAsJid("address");
+                                if (room != null) {
+                                    searchResults.add(new ChannelSearchResult(name,description,room));
+                                } else {
+                                    Log.d(Config.LOGTAG,"skipping because room was null");
+                                }
+                            }
+                        }
+                    } else {
+                        Log.d(Config.LOGTAG,"result was null");
+                    }
+                } else {
+                    Log.d(Config.LOGTAG,response.toString());
+                }
+                if (listener != null) {
+                    listener.onChannelSearchResultsFound(searchResults);
+                }
+
+            }
+        });
+    }
+
+    public interface OnChannelSearchResultsFound {
+        void onChannelSearchResultsFound(List<ChannelSearchResult> results);
+    }
+
     public boolean isDataSaverDisabled() {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
             ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);

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

@@ -0,0 +1,165 @@
+package eu.siacs.conversations.ui;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.SharedPreferences;
+import android.databinding.DataBindingUtil;
+import android.os.Bundle;
+import android.support.v7.widget.Toolbar;
+import android.text.Html;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.ActivityChannelDiscoveryBinding;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.ChannelSearchResult;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.ui.adapter.ChannelSearchResultAdapter;
+import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
+import eu.siacs.conversations.utils.AccountUtils;
+import rocks.xmpp.addr.Jid;
+
+public class ChannelDiscoveryActivity extends XmppActivity implements MenuItem.OnActionExpandListener, TextView.OnEditorActionListener, XmppConnectionService.OnChannelSearchResultsFound, ChannelSearchResultAdapter.OnChannelSearchResultSelected {
+
+    private static final String CHANNEL_DISCOVERY_OPT_IN = "channel_discovery_opt_in";
+
+    private final ChannelSearchResultAdapter adapter = new ChannelSearchResultAdapter();
+
+    private EditText mSearchEditText;
+
+    private boolean optedIn = false;
+
+    @Override
+    protected void refreshUiReal() {
+
+    }
+
+    @Override
+    void onBackendConnected() {
+        if (optedIn) {
+            xmppConnectionService.discoverChannels(null, this);
+        }
+    }
+
+    @Override
+    protected void onCreate(final Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        ActivityChannelDiscoveryBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_channel_discovery);
+        setSupportActionBar((Toolbar) binding.toolbar);
+        configureActionBar(getSupportActionBar(), true);
+        binding.list.setAdapter(this.adapter);
+        this.adapter.setOnChannelSearchResultSelectedListener(this);
+        optedIn = getPreferences().getBoolean(CHANNEL_DISCOVERY_OPT_IN, false);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(final Menu menu) {
+        getMenuInflater().inflate(R.menu.muc_users_activity, menu);
+        final MenuItem menuSearchView = menu.findItem(R.id.action_search);
+        final View mSearchView = menuSearchView.getActionView();
+        mSearchEditText = mSearchView.findViewById(R.id.search_field);
+        mSearchEditText.setHint(R.string.search_channels);
+        mSearchEditText.setOnEditorActionListener(this);
+        menuSearchView.setOnActionExpandListener(this);
+        return true;
+    }
+
+    @Override
+    public boolean onMenuItemActionExpand(MenuItem item) {
+        mSearchEditText.post(() -> {
+            mSearchEditText.requestFocus();
+            final InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
+            imm.showSoftInput(mSearchEditText, InputMethodManager.SHOW_IMPLICIT);
+        });
+        return true;
+    }
+
+    @Override
+    public boolean onMenuItemActionCollapse(MenuItem item) {
+        final InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
+        imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY);
+        mSearchEditText.setText("");
+        adapter.submitList(Collections.emptyList());
+        if (optedIn) {
+            xmppConnectionService.discoverChannels(null, this);
+        }
+        return true;
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        if (!optedIn) {
+            final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+            builder.setTitle(R.string.channel_discovery_opt_in_title);
+            builder.setMessage(Html.fromHtml(getString(R.string.channel_discover_opt_in_message)));
+            builder.setNegativeButton(R.string.cancel, (dialog, which) -> finish());
+            builder.setPositiveButton(R.string.confirm, (dialog, which) -> optIn());
+            builder.setOnCancelListener(dialog -> finish());
+            final AlertDialog dialog = builder.create();
+            dialog.setCanceledOnTouchOutside(false);
+            dialog.show();
+        }
+    }
+
+    private void optIn() {
+        SharedPreferences preferences = getPreferences();
+        preferences.edit().putBoolean(CHANNEL_DISCOVERY_OPT_IN,true).apply();
+        optedIn = true;
+        xmppConnectionService.discoverChannels(null, this);
+    }
+
+    @Override
+    public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+        if (optedIn) {
+            xmppConnectionService.discoverChannels(v.getText().toString(), this);
+        }
+        adapter.submitList(Collections.emptyList());
+        SoftKeyboardUtils.hideSoftKeyboard(this);
+        return true;
+    }
+
+    @Override
+    public void onChannelSearchResultsFound(List<ChannelSearchResult> results) {
+        runOnUiThread(() -> adapter.submitList(results));
+
+    }
+
+    @Override
+    public void onChannelSearchResult(final ChannelSearchResult result) {
+        List<String> accounts = AccountUtils.getEnabledAccounts(xmppConnectionService);
+        if (accounts.size() == 1) {
+            joinChannelSearchResult(accounts.get(0),result);
+        } else if (accounts.size() > 0){
+            final AtomicReference<String> account = new AtomicReference<>(accounts.get(0));
+            AlertDialog.Builder builder = new AlertDialog.Builder(this);
+            builder.setTitle(R.string.choose_account);
+            builder.setSingleChoiceItems(accounts.toArray(new CharSequence[0]), 0, (dialog, which) -> account.set(accounts.get(which)));
+            builder.setPositiveButton(R.string.join, (dialog, which) -> joinChannelSearchResult(account.get(), result));
+            builder.setNegativeButton(R.string.cancel, null);
+            builder.create().show();
+        }
+
+    }
+
+    public void joinChannelSearchResult(String accountJid, ChannelSearchResult result) {
+        Account account = xmppConnectionService.findAccountByJid(Jid.of(accountJid));
+        final Conversation conversation = xmppConnectionService.findOrCreateConversation(account, result.getRoom(), true, true, true);
+        switchToConversation(conversation);
+    }
+}

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

@@ -313,6 +313,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
 				prefilled = null;
 			}
 			switch (actionItem.getId()) {
+				case R.id.discover_public_channels:
+					startActivity(new Intent(this, ChannelDiscoveryActivity.class));
+					break;
 				case R.id.join_public_channel:
 					showJoinConferenceDialog(prefilled);
 					break;
@@ -781,15 +784,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
 			this.mPostponedActivityResult = null;
 		}
 		this.mActivatedAccounts.clear();
-		for (Account account : xmppConnectionService.getAccounts()) {
-			if (account.getStatus() != Account.State.DISABLED) {
-				if (Config.DOMAIN_LOCK != null) {
-					this.mActivatedAccounts.add(account.getJid().getLocal());
-				} else {
-					this.mActivatedAccounts.add(account.getJid().asBareJid().toString());
-				}
-			}
-		}
+		this.mActivatedAccounts.addAll(AccountUtils.getEnabledAccounts(xmppConnectionService));
 		configureHomeButton();
 		Intent intent = pendingViewIntent.pop();
 		if (intent != null && processViewIntent(intent)) {

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

@@ -0,0 +1,78 @@
+package eu.siacs.conversations.ui.adapter;
+
+import android.databinding.DataBindingUtil;
+import android.support.annotation.NonNull;
+import android.support.v7.recyclerview.extensions.ListAdapter;
+import android.support.v7.util.DiffUtil;
+import android.support.v7.widget.RecyclerView;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.SearchResultItemBinding;
+import eu.siacs.conversations.entities.ChannelSearchResult;
+import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+
+public class ChannelSearchResultAdapter extends ListAdapter<ChannelSearchResult, ChannelSearchResultAdapter.ViewHolder> {
+
+    private OnChannelSearchResultSelected listener;
+
+    private static final DiffUtil.ItemCallback<ChannelSearchResult> DIFF = new DiffUtil.ItemCallback<ChannelSearchResult>() {
+        @Override
+        public boolean areItemsTheSame(@NonNull ChannelSearchResult a, @NonNull ChannelSearchResult b) {
+            return false;
+        }
+
+        @Override
+        public boolean areContentsTheSame(@NonNull ChannelSearchResult a, @NonNull ChannelSearchResult b) {
+            return a.equals(b);
+        }
+    };
+
+    public ChannelSearchResultAdapter() {
+        super(DIFF);
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
+        return new ViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.search_result_item,viewGroup,false));
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
+        final ChannelSearchResult searchResult = getItem(position);
+        viewHolder.binding.name.setText(searchResult.getName());
+        final String description = searchResult.getDescription();
+        if (TextUtils.isEmpty(description)) {
+            viewHolder.binding.description.setVisibility(View.GONE);
+        } else {
+            viewHolder.binding.description.setText(description);
+            viewHolder.binding.description.setVisibility(View.VISIBLE);
+        }
+        viewHolder.binding.room.setText(searchResult.getRoom().asBareJid().toString());
+        AvatarWorkerTask.loadAvatar(searchResult, viewHolder.binding.avatar, R.dimen.avatar);
+        viewHolder.binding.getRoot().setOnClickListener(v -> listener.onChannelSearchResult(searchResult));
+    }
+
+    public void setOnChannelSearchResultSelectedListener(OnChannelSearchResultSelected listener) {
+        this.listener = listener;
+    }
+
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+
+        private final SearchResultItemBinding binding;
+
+        private ViewHolder(SearchResultItemBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+
+    public interface OnChannelSearchResultSelected {
+        void onChannelSearchResult(ChannelSearchResult result);
+    }
+}

src/main/java/eu/siacs/conversations/utils/AccountUtils.java 🔗

@@ -6,8 +6,10 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.widget.Toast;
 
+import java.util.ArrayList;
 import java.util.List;
 
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.services.XmppConnectionService;
@@ -22,6 +24,20 @@ public class AccountUtils {
     }
 
 
+    public static List<String> getEnabledAccounts(final XmppConnectionService service) {
+        ArrayList<String> accounts = new ArrayList<>();
+        for (Account account : service.getAccounts()) {
+            if (account.getStatus() != Account.State.DISABLED) {
+                if (Config.DOMAIN_LOCK != null) {
+                    accounts.add(account.getJid().getLocal());
+                } else {
+                    accounts.add(account.getJid().asBareJid().toString());
+                }
+            }
+        }
+        return accounts;
+    }
+
     public static Account getFirstEnabled(XmppConnectionService service) {
         final List<Account> accounts = service.getAccounts();
         for(Account account : accounts) {

src/main/res/menu/channel_discovery_activity.xml 🔗

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+      xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_search"
+        app:actionLayout="@layout/actionview_search"
+        android:icon="?attr/icon_search"
+        app:showAsAction="collapseActionView|always"
+        android:title="@string/search"/>
+</menu>

src/main/res/menu/start_conversation_fab_submenu.xml 🔗

@@ -1,5 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/discover_public_channels"
+        android:title="@string/discover_channels"
+        android:icon="@drawable/ic_search_white_24dp"/>
     <item
         android:id="@+id/join_public_channel"
         android:title="@string/join_public_channel"

src/main/res/values/strings.xml 🔗

@@ -857,4 +857,8 @@
     <string name="search_participants">Search participants</string>
     <string name="file_too_large">File too large</string>
     <string name="attach">Attach</string>
+    <string name="discover_channels">Discover channels</string>
+    <string name="search_channels">Search channels</string>
+    <string name="channel_discovery_opt_in_title">Possible privacy violation!</string>
+    <string name="channel_discover_opt_in_message"><![CDATA[Channel discovery uses a third party service called <a href="https://search.jabber.network">search.jabber.network</a>.<br><br>Using this feature will transmit your Jabber ID and search terms to that service. See their <a href="https://search.jabber.network/privacy">Privacy Policy</a> for more information.]]></string>
 </resources>