From a0f88aa9b4292e22b18bf8b19b2e722b815012b8 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Sun, 31 Mar 2019 17:12:01 +0200 Subject: [PATCH] implement channel discovery over jabber.search.network --- .../res/layout/activity_channel_discovery.xml | 33 ++++ .../res/layout/search_result_item.xml | 48 +++++ src/main/AndroidManifest.xml | 1 + .../java/eu/siacs/conversations/Config.java | 3 + .../entities/ChannelSearchResult.java | 35 ++++ .../conversations/services/AvatarService.java | 7 + .../services/XmppConnectionService.java | 55 ++++++ .../ui/ChannelDiscoveryActivity.java | 165 ++++++++++++++++++ .../ui/StartConversationActivity.java | 13 +- .../adapter/ChannelSearchResultAdapter.java | 78 +++++++++ .../conversations/utils/AccountUtils.java | 16 ++ .../res/menu/channel_discovery_activity.xml | 11 ++ .../menu/start_conversation_fab_submenu.xml | 4 + src/main/res/values/strings.xml | 4 + 14 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 src/conversations/res/layout/activity_channel_discovery.xml create mode 100644 src/conversations/res/layout/search_result_item.xml create mode 100644 src/main/java/eu/siacs/conversations/entities/ChannelSearchResult.java create mode 100644 src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java create mode 100644 src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java create mode 100644 src/main/res/menu/channel_discovery_activity.xml diff --git a/src/conversations/res/layout/activity_channel_discovery.xml b/src/conversations/res/layout/activity_channel_discovery.xml new file mode 100644 index 000000000..679c5156e --- /dev/null +++ b/src/conversations/res/layout/activity_channel_discovery.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/conversations/res/layout/search_result_item.xml b/src/conversations/res/layout/search_result_item.xml new file mode 100644 index 000000000..2d77be1fb --- /dev/null +++ b/src/conversations/res/layout/search_result_item.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index b6a8faac1..6b43106f9 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -283,6 +283,7 @@ + diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java index 427502487..4626bffc8 100644 --- a/src/main/java/eu/siacs/conversations/Config.java +++ b/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; diff --git a/src/main/java/eu/siacs/conversations/entities/ChannelSearchResult.java b/src/main/java/eu/siacs/conversations/entities/ChannelSearchResult.java new file mode 100644 index 000000000..0222f2f85 --- /dev/null +++ b/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()); + } +} diff --git a/src/main/java/eu/siacs/conversations/services/AvatarService.java b/src/main/java/eu/siacs/conversations/services/AvatarService.java index f4807a11b..055c50005 100644 --- a/src/main/java/eu/siacs/conversations/services/AvatarService.java +++ b/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); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 41e3ff8d9..3c2164902 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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 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 results); + } + public boolean isDataSaverDisabled() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); diff --git a/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java b/src/main/java/eu/siacs/conversations/ui/ChannelDiscoveryActivity.java new file mode 100644 index 000000000..5b0c30ed3 --- /dev/null +++ b/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 results) { + runOnUiThread(() -> adapter.submitList(results)); + + } + + @Override + public void onChannelSearchResult(final ChannelSearchResult result) { + List accounts = AccountUtils.getEnabledAccounts(xmppConnectionService); + if (accounts.size() == 1) { + joinChannelSearchResult(accounts.get(0),result); + } else if (accounts.size() > 0){ + final AtomicReference 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); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java b/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java index 2a0066e1b..d776d0ff0 100644 --- a/src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java +++ b/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)) { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java new file mode 100644 index 000000000..ae1c70c1e --- /dev/null +++ b/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 { + + private OnChannelSearchResultSelected listener; + + private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() { + @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); + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java b/src/main/java/eu/siacs/conversations/utils/AccountUtils.java index f546a69e4..df8d128d3 100644 --- a/src/main/java/eu/siacs/conversations/utils/AccountUtils.java +++ b/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 getEnabledAccounts(final XmppConnectionService service) { + ArrayList 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 accounts = service.getAccounts(); for(Account account : accounts) { diff --git a/src/main/res/menu/channel_discovery_activity.xml b/src/main/res/menu/channel_discovery_activity.xml new file mode 100644 index 000000000..209bb27e0 --- /dev/null +++ b/src/main/res/menu/channel_discovery_activity.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/main/res/menu/start_conversation_fab_submenu.xml b/src/main/res/menu/start_conversation_fab_submenu.xml index 76a576d6a..bfaca0727 100644 --- a/src/main/res/menu/start_conversation_fab_submenu.xml +++ b/src/main/res/menu/start_conversation_fab_submenu.xml @@ -1,5 +1,9 @@ + Search participants File too large Attach + Discover channels + Search channels + Possible privacy violation! + search.jabber.network.

Using this feature will transmit your Jabber ID and search terms to that service. See their Privacy Policy for more information.]]>