From 1d632f3c96ffcf5fa03045ecb6d912e219af0861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Thu, 12 May 2022 13:32:18 +0200 Subject: [PATCH] Implement global message search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../adapters/items/LoadMoreResultsItem.kt | 79 +++++++ .../talk/adapters/items/MessageResultItem.kt | 115 ++++++++++ .../adapters/items/MessagesTextHeaderItem.kt | 36 +++ .../java/com/nextcloud/talk/api/NcApi.java | 10 + .../ConversationsListController.java | 206 ++++++++++++++---- .../controllers/util/MessageSearchHelper.kt | 99 +++++++++ .../talk/dagger/modules/RepositoryModule.kt | 7 + .../talk/models/domain/SearchMessageEntry.kt | 31 +++ .../json/unifiedsearch/UnifiedSearchEntry.kt | 48 ++++ .../json/unifiedsearch/UnifiedSearchOCS.kt | 38 ++++ .../unifiedsearch/UnifiedSearchOverall.kt | 35 +++ .../UnifiedSearchResponseData.kt | 43 ++++ .../unifiedsearch/UnifiedSearchRepository.kt | 26 +++ .../UnifiedSearchRepositoryImpl.kt | 83 +++++++ .../com/nextcloud/talk/utils/ApiUtils.java | 7 +- .../com/nextcloud/talk/utils/Debouncer.kt | 34 +++ .../nextcloud/talk/utils/DisplayUtils.java | 25 +++ app/src/main/res/layout/rv_item_load_more.xml | 62 ++++++ .../res/layout/rv_item_search_message.xml | 80 +++++++ app/src/main/res/values/strings.xml | 2 + 20 files changed, 1025 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt create mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt create mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt create mode 100644 app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt create mode 100644 app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt create mode 100644 app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt create mode 100644 app/src/main/res/layout/rv_item_load_more.xml create mode 100644 app/src/main/res/layout/rv_item_search_message.xml diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt new file mode 100644 index 000000000..5ae33ee15 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.adapters.items + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.talk.R +import com.nextcloud.talk.databinding.RvItemLoadMoreBinding +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFilterable +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.viewholders.FlexibleViewHolder + +object LoadMoreResultsItem : + AbstractFlexibleItem(), + IFilterable { + + // layout is used as view type for uniqueness + const val VIEW_TYPE: Int = R.layout.rv_item_load_more + + class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : + FlexibleViewHolder(view, adapter) { + var binding: RvItemLoadMoreBinding + + init { + binding = RvItemLoadMoreBinding.bind(view) + } + } + + override fun getLayoutRes(): Int = R.layout.rv_item_load_more + + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): ViewHolder = ViewHolder(view, adapter) + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: ViewHolder, + position: Int, + payloads: MutableList? + ) { + // nothing, it's immutable + } + + override fun filter(constraint: String?): Boolean = true + + override fun getItemViewType(): Int { + return VIEW_TYPE + } + + override fun equals(other: Any?): Boolean { + return other is LoadMoreResultsItem + } + + override fun hashCode(): Int { + return 0 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt new file mode 100644 index 000000000..725ed5e1c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.adapters.items + +import android.content.Context +import android.text.SpannableString +import android.view.View +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.talk.R +import com.nextcloud.talk.databinding.RvItemSearchMessageBinding +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.utils.DisplayUtils +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFilterable +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.flexibleadapter.items.ISectionable +import eu.davidea.viewholders.FlexibleViewHolder + +data class MessageResultItem constructor( + private val context: Context, + private val currentUser: UserEntity, + val messageEntry: SearchMessageEntry, + private val showHeader: Boolean +) : + AbstractFlexibleItem(), + IFilterable, + ISectionable { + + class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : + FlexibleViewHolder(view, adapter) { + var binding: RvItemSearchMessageBinding + + init { + binding = RvItemSearchMessageBinding.bind(view) + } + } + + override fun getLayoutRes(): Int = R.layout.rv_item_search_message + + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): ViewHolder = ViewHolder(view, adapter) + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: ViewHolder, + position: Int, + payloads: MutableList? + ) { + holder.binding.conversationTitle.text = messageEntry.title + bindMessageExcerpt(holder) + loadImage(holder) + } + + private fun bindMessageExcerpt(holder: ViewHolder) { + val messageSpannable = SpannableString(messageEntry.messageExcerpt) + val highlightColor = ContextCompat.getColor(context, R.color.colorPrimary) + val highlightedSpan = DisplayUtils.searchAndColor(messageSpannable, messageEntry.searchTerm, highlightColor) + holder.binding.messageExcerpt.text = highlightedSpan + } + + private fun loadImage(holder: ViewHolder) { + DisplayUtils.loadAvatarPlaceholder(holder.binding.thumbnail) + if (messageEntry.thumbnailURL != null) { + val imageRequest = DisplayUtils.getImageRequestForUrl( + messageEntry.thumbnailURL, + currentUser + ) + DisplayUtils.loadImage(holder.binding.thumbnail, imageRequest) + } + } + + override fun filter(constraint: String?): Boolean = true + + override fun getItemViewType(): Int { + return VIEW_TYPE + } + + companion object { + // layout is used as view type for uniqueness + const val VIEW_TYPE: Int = R.layout.rv_item_search_message + } + + override fun getHeader(): GenericTextHeaderItem = MessagesTextHeaderItem(context) + .apply { + isHidden = showHeader // FlexibleAdapter needs this hack for some reason + } + + override fun setHeader(header: GenericTextHeaderItem?) { + // nothing, header is always the same + } +} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt new file mode 100644 index 000000000..24ddeabc5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.adapters.items + +import android.content.Context +import com.nextcloud.talk.R + +class MessagesTextHeaderItem(context: Context) : GenericTextHeaderItem(context.getString(R.string.messages)) { + companion object { + /** + * "Random" value, just has to be different than other view types + */ + const val VIEW_TYPE = 1120391230 + } + + override fun getItemViewType(): Int = VIEW_TYPE +} diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index e160ebfda..35742aeb5 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -45,6 +45,7 @@ import com.nextcloud.talk.models.json.signaling.SignalingOverall; import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall; import com.nextcloud.talk.models.json.status.StatusOverall; import com.nextcloud.talk.models.json.statuses.StatusesOverall; +import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchOverall; import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall; import com.nextcloud.talk.models.json.userprofile.UserProfileOverall; @@ -519,4 +520,13 @@ public interface NcApi { Observable getReactions(@Header("Authorization") String authorization, @Url String url, @Query("reaction") String reaction); + + // TODO use path params instead of passing URL + @GET + Observable performUnifiedSearch(@Header("Authorization") String authorization, + @Url String url, + @Query("term") String term, + @Query("from") String fromUrl, + @Query("limit") Integer limit, + @Query("cursor") Integer cursor); } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java index dfb72b333..a84ec2a25 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -68,9 +68,13 @@ import com.nextcloud.talk.R; import com.nextcloud.talk.activities.MainActivity; import com.nextcloud.talk.adapters.items.ConversationItem; import com.nextcloud.talk.adapters.items.GenericTextHeaderItem; +import com.nextcloud.talk.adapters.items.LoadMoreResultsItem; +import com.nextcloud.talk.adapters.items.MessageResultItem; +import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem; import com.nextcloud.talk.api.NcApi; import com.nextcloud.talk.application.NextcloudTalkApplication; import com.nextcloud.talk.controllers.base.BaseController; +import com.nextcloud.talk.controllers.util.MessageSearchHelper; import com.nextcloud.talk.events.ConversationsListFetchDataEvent; import com.nextcloud.talk.events.EventStatus; import com.nextcloud.talk.interfaces.ConversationMenuInterface; @@ -80,15 +84,18 @@ import com.nextcloud.talk.jobs.DeleteConversationWorker; import com.nextcloud.talk.jobs.UploadAndShareFilesWorker; import com.nextcloud.talk.models.database.CapabilitiesUtil; import com.nextcloud.talk.models.database.UserEntity; +import com.nextcloud.talk.models.domain.SearchMessageEntry; import com.nextcloud.talk.models.json.conversations.Conversation; import com.nextcloud.talk.models.json.status.Status; import com.nextcloud.talk.models.json.statuses.StatusesOverall; +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository; import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment; import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog; import com.nextcloud.talk.utils.ApiUtils; import com.nextcloud.talk.utils.AttendeePermissionsUtil; import com.nextcloud.talk.utils.ClosedInterfaceImpl; import com.nextcloud.talk.utils.ConductorRemapping; +import com.nextcloud.talk.utils.Debouncer; import com.nextcloud.talk.utils.DisplayUtils; import com.nextcloud.talk.utils.UriUtils; import com.nextcloud.talk.utils.bundle.BundleKeys; @@ -131,6 +138,8 @@ import butterknife.BindView; import eu.davidea.flexibleadapter.FlexibleAdapter; import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager; import eu.davidea.flexibleadapter.items.AbstractFlexibleItem; +import eu.davidea.flexibleadapter.items.IHeader; +import io.reactivex.Observable; import io.reactivex.Observer; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -145,6 +154,10 @@ public class ConversationsListController extends BaseController implements Searc public static final int ID_DELETE_CONVERSATION_DIALOG = 0; public static final int UNREAD_BUBBLE_DELAY = 2500; private static final String KEY_SEARCH_QUERY = "ContactsController.searchQuery"; + + public static final int SEARCH_DEBOUNCE_INTERVAL_MS = 300; + public static final int SEARCH_MIN_CHARS = 2; + private final Bundle bundle; @Inject UserUtils userUtils; @@ -161,6 +174,9 @@ public class ConversationsListController extends BaseController implements Searc @Inject AppPreferences appPreferences; + @Inject + UnifiedSearchRepository unifiedSearchRepository; + @BindView(R.id.recycler_view) RecyclerView recyclerView; @@ -220,6 +236,10 @@ public class ConversationsListController extends BaseController implements Searc private HashMap userStatuses = new HashMap<>(); + private Debouncer searchDebouncer = new Debouncer(SEARCH_DEBOUNCE_INTERVAL_MS); + + private MessageSearchHelper searchHelper; + public ConversationsListController(Bundle bundle) { super(); setHasOptionsMenu(true); @@ -306,6 +326,8 @@ public class ConversationsListController extends BaseController implements Searc return; } + searchHelper = new MessageSearchHelper(currentUser, unifiedSearchRepository); + credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); if (getActivity() != null && getActivity() instanceof MainActivity) { loadUserAvatar(((MainActivity) getActivity()).binding.switchAccountButton); @@ -419,6 +441,11 @@ public class ConversationsListController extends BaseController implements Searc adapter.setHeadersShown(false); adapter.updateDataSet(conversationItems, false); adapter.hideAllHeaders(); + if (searchHelper != null) { + // cancel any pending searches + searchHelper.cancelSearch(); + swipeRefreshLayout.setRefreshing(false); + } if (swipeRefreshLayout != null) { swipeRefreshLayout.setEnabled(true); } @@ -427,8 +454,8 @@ public class ConversationsListController extends BaseController implements Searc MainActivity activity = (MainActivity) getActivity(); if (activity != null) { activity.binding.appBar.setStateListAnimator(AnimatorInflater.loadStateListAnimator( - activity.binding.appBar.getContext(), - R.animator.appbar_elevation_off) + activity.binding.appBar.getContext(), + R.animator.appbar_elevation_off) ); activity.binding.toolbar.setVisibility(View.GONE); activity.binding.searchToolbar.setVisibility(View.VISIBLE); @@ -845,20 +872,73 @@ public class ConversationsListController extends BaseController implements Searc } @Override - public boolean onQueryTextChange(String newText) { - if (adapter.hasNewFilter(newText) || !TextUtils.isEmpty(searchQuery)) { - if (!TextUtils.isEmpty(searchQuery)) { - adapter.setFilter(searchQuery); - searchQuery = ""; - adapter.filterItems(); - } else { - adapter.setFilter(newText); - adapter.filterItems(300); - } + public boolean onQueryTextChange(final String newText) { + if (!TextUtils.isEmpty(searchQuery)) { + final String filter = searchQuery; + searchQuery = ""; + performFilterAndSearch(filter); + } else if (adapter.hasNewFilter(newText)) { + new Handler(); + searchDebouncer.debounce(() -> { + performFilterAndSearch(newText); + }); } return true; } + private void performFilterAndSearch(String filter) { + if (filter.length() >= SEARCH_MIN_CHARS) { + clearMessageSearchResults(); + adapter.setFilter(filter); + adapter.filterItems(); + startMessageSearch(filter); + } else { + resetSearchResults(); + } + } + + private void resetSearchResults() { + clearMessageSearchResults(); + adapter.setFilter(""); + adapter.filterItems(); + } + + private void clearMessageSearchResults() { + final IHeader firstHeader = adapter.getSectionHeader(0); + if (firstHeader != null && firstHeader.getItemViewType() == MessagesTextHeaderItem.VIEW_TYPE) { + adapter.removeSection(firstHeader); + } else { + adapter.removeItemsOfType(MessageResultItem.VIEW_TYPE); + } + adapter.removeItemsOfType(LoadMoreResultsItem.VIEW_TYPE); + } + + @SuppressLint("CheckResult") // handled by helper + private void startMessageSearch(final String search) { + swipeRefreshLayout.setRefreshing(true); + searchHelper + .startMessageSearch(search) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::onMessageSearchResult, + this::onMessageSearchError); + } + + @SuppressLint("CheckResult") // handled by helper + private void loadMoreMessages() { + swipeRefreshLayout.setRefreshing(true); + final Observable observable = searchHelper.loadMore(); + if (observable != null) { + observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::onMessageSearchResult, + this::onMessageSearchError); + } + } + + @Override public boolean onQueryTextSubmit(String query) { return onQueryTextChange(query); @@ -871,38 +951,59 @@ public class ConversationsListController extends BaseController implements Searc @Override public boolean onItemClick(View view, int position) { - try { - selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel(); - - if (selectedConversation != null && getActivity() != null) { - boolean hasChatPermission = - new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser); - - if (showShareToScreen) { - if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { - handleSharedData(); - showShareToScreen = false; - } else { - Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); - } - } else if (forwardMessage) { - if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { - openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT())); - forwardMessage = false; - } else { - Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); - } - } else { - openConversation(); - } - } - } catch (ClassCastException e) { - Log.w(TAG, "failed to cast clicked item to ConversationItem. Most probably a heading was clicked. This is" + - " just ignored.", e); + final AbstractFlexibleItem item = adapter.getItem(position); + if (item instanceof ConversationItem) { + showConversation(((ConversationItem) Objects.requireNonNull(item)).getModel()); + } else if (item instanceof MessageResultItem) { + MessageResultItem messageItem = (MessageResultItem) item; + String conversationToken = messageItem.getMessageEntry().getConversationToken(); + showConversationByToken(conversationToken); + } else if (item instanceof LoadMoreResultsItem) { + loadMoreMessages(); } + return true; } + private void showConversationByToken(String conversationToken) { + Conversation conversation = null; + for (AbstractFlexibleItem absItem : conversationItems) { + ConversationItem conversationItem = ((ConversationItem) absItem); + if (conversationItem.getModel().getToken().equals(conversationToken)) { + conversation = conversationItem.getModel(); + } + } + if (conversation != null) { + showConversation(conversation); + } + } + + private void showConversation(@Nullable final Conversation conversation) { + selectedConversation = conversation; + if (selectedConversation != null && getActivity() != null) { + boolean hasChatPermission = + new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser); + + if (showShareToScreen) { + if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { + handleSharedData(); + showShareToScreen = false; + } else { + Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); + } + } else if (forwardMessage) { + if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) { + openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT())); + forwardMessage = false; + } else { + Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show(); + } + } else { + openConversation(); + } + } + } + private Boolean isReadOnlyConversation(Conversation conversation) { return conversation.getConversationReadOnlyState() == Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY; @@ -1274,4 +1375,29 @@ public class ConversationsListController extends BaseController implements Searc public AppBarLayoutType getAppBarLayoutType() { return AppBarLayoutType.SEARCH_BAR; } + + public void onMessageSearchResult(@NonNull MessageSearchHelper.MessageSearchResults results) { + if (searchView.getQuery().length() > 0) { + clearMessageSearchResults(); + final List entries = results.getMessages(); + if (entries.size() > 0) { + List adapterItems = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + final boolean showHeader = i == 0; + adapterItems.add(new MessageResultItem(context, currentUser, entries.get(i), showHeader)); + } + if (results.getHasMore()) { + adapterItems.add(LoadMoreResultsItem.INSTANCE); + } + adapter.addItems(0, adapterItems); + recyclerView.scrollToPosition(0); + } + } + swipeRefreshLayout.setRefreshing(false); + } + + public void onMessageSearchError(@NonNull Throwable throwable) { + handleHttpExceptions(throwable); + swipeRefreshLayout.setRefreshing(false); + } } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt b/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt new file mode 100644 index 000000000..1432b02ac --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.controllers.util + +import android.util.Log +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository +import io.reactivex.Observable +import io.reactivex.disposables.Disposable + +class MessageSearchHelper( + private val user: UserEntity, + private val unifiedSearchRepository: UnifiedSearchRepository, +) { + + data class MessageSearchResults(val messages: List, val hasMore: Boolean) + + private var unifiedSearchDisposable: Disposable? = null + private var previousSearch: String? = null + private var previousCursor: Int = 0 + private var previousResults: List = emptyList() + + fun startMessageSearch(search: String): Observable { + return doSearch(search) + } + + fun loadMore(): Observable? { + previousSearch?.let { + return doSearch(it, previousCursor) + } + return null + } + + fun cancelSearch() { + disposeIfPossible() + } + + private fun doSearch(search: String, cursor: Int = 0): Observable { + resetResultsIfNeeded(search) + disposeIfPossible() + return unifiedSearchRepository.searchMessages(user, search, cursor) + .map { results -> + previousSearch = search + previousCursor = results.cursor + previousResults = previousResults + results.entries + MessageSearchResults(previousResults, results.hasMore) + } + .doOnSubscribe { + unifiedSearchDisposable = it + } + .doOnError { throwable -> + Log.e(TAG, "message search - ERROR", throwable) + resetCachedData() + disposeIfPossible() + } + .doOnComplete(this::disposeIfPossible) + } + + private fun resetResultsIfNeeded(search: String) { + if (search != previousSearch) { + resetCachedData() + } + } + + private fun resetCachedData() { + previousSearch = null + previousCursor = 0 + previousResults = emptyList() + } + + private fun disposeIfPossible() { + unifiedSearchDisposable?.dispose() + unifiedSearchDisposable = null + } + + companion object { + private val TAG = MessageSearchHelper::class.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index 50da11477..79d3489de 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -22,6 +22,8 @@ package com.nextcloud.talk.dagger.modules import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepositoryImpl import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository import com.nextcloud.talk.shareditems.repositories.SharedItemsRepositoryImpl import dagger.Module @@ -33,4 +35,9 @@ class RepositoryModule { fun provideSharedItemsRepository(ncApi: NcApi): SharedItemsRepository { return SharedItemsRepositoryImpl(ncApi) } + + @Provides + fun provideUnifiedSearchRepository(ncApi: NcApi): UnifiedSearchRepository { + return UnifiedSearchRepositoryImpl(ncApi) + } } diff --git a/app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt b/app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt new file mode 100644 index 000000000..2b3d233d4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.models.domain + +data class SearchMessageEntry( + val searchTerm: String, + val thumbnailURL: String?, + val title: String, + val messageExcerpt: String, + val conversationToken: String, + val messageId: String? +) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt new file mode 100644 index 000000000..0cfe1d256 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.models.json.unifiedsearch + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.android.parcel.Parcelize + +@Parcelize +@JsonObject +data class UnifiedSearchEntry( + @JsonField(name = ["thumbnailUrl"]) + var thumbnailUrl: String?, + @JsonField(name = ["title"]) + var title: String?, + @JsonField(name = ["subline"]) + var subline: String?, + @JsonField(name = ["resourceUrl"]) + var resourceUrl: String?, + @JsonField(name = ["icon"]) + var icon: String?, + @JsonField(name = ["rounded"]) + var rounded: Boolean?, + @JsonField(name = ["attributes"]) + var attributes: Map?, +) : Parcelable { + constructor() : this(null, null, null, null, null, null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt new file mode 100644 index 000000000..ba6e7a89a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.nextcloud.talk.models.json.unifiedsearch + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.android.parcel.Parcelize + +@Parcelize +@JsonObject +data class UnifiedSearchOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: UnifiedSearchResponseData? +) : Parcelable { + // Empty constructor needed for JsonObject + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt new file mode 100644 index 000000000..d1db94f5e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2022 Marcel Hibbe + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.nextcloud.talk.models.json.unifiedsearch + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.android.parcel.Parcelize + +@Parcelize +@JsonObject +data class UnifiedSearchOverall( + @JsonField(name = ["ocs"]) + var ocs: UnifiedSearchOCS? +) : Parcelable { + // Empty constructor needed for JsonObject + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt new file mode 100644 index 000000000..a05857d4b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.models.json.unifiedsearch + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.android.parcel.Parcelize + +@Parcelize +@JsonObject +data class UnifiedSearchResponseData( + @JsonField(name = ["name"]) + var name: String?, + @JsonField(name = ["isPaginated"]) + var paginated: Boolean?, + @JsonField(name = ["entries"]) + var entries: List?, + @JsonField(name = ["cursor"]) + var cursor: Int? +) : Parcelable { + // empty constructor needed for JsonObject + constructor() : this(null, null, null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt new file mode 100644 index 000000000..43a416395 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt @@ -0,0 +1,26 @@ +package com.nextcloud.talk.repositories.unifiedsearch + +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import io.reactivex.Observable + +interface UnifiedSearchRepository { + data class UnifiedSearchResults( + val cursor: Int, + val hasMore: Boolean, + val entries: List + ) + + fun searchMessages( + userEntity: UserEntity, + searchTerm: String, + cursor: Int = 0, + limit: Int = DEFAULT_PAGE_SIZE + ): Observable> + + fun searchInRoom(text: String, roomId: String): Observable> + + companion object { + private const val DEFAULT_PAGE_SIZE = 5 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt new file mode 100644 index 000000000..430aca3d4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.repositories.unifiedsearch + +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchEntry +import com.nextcloud.talk.models.json.unifiedsearch.UnifiedSearchResponseData +import com.nextcloud.talk.utils.ApiUtils +import io.reactivex.Observable + +class UnifiedSearchRepositoryImpl(private val api: NcApi) : UnifiedSearchRepository { + + override fun searchMessages( + userEntity: UserEntity, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + val apiObservable = api.performUnifiedSearch( + ApiUtils.getCredentials(userEntity.username, userEntity.token), + ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE), + searchTerm, + null, + limit, + cursor + ) + return apiObservable.map { mapToMessageResults(it.ocs?.data!!, searchTerm, limit) } + } + + override fun searchInRoom(text: String, roomId: String): Observable> { + TODO() + } + + companion object { + private const val PROVIDER_TALK_MESSAGE = "talk-message" + private const val PROVIDER_TALK_MESSAGE_CURRENT = "talk-message-current" + + private const val ATTRIBUTE_CONVERSATION = "conversation" + private const val ATTRIBUTE_MESSAGE_ID = "messageId" + + private fun mapToMessageResults(data: UnifiedSearchResponseData, searchTerm: String, limit: Int): + UnifiedSearchRepository.UnifiedSearchResults { + val entries = data.entries?.map { it -> mapToMessage(it, searchTerm) } + val cursor = data.cursor ?: 0 + val hasMore = entries?.size == limit + return UnifiedSearchRepository.UnifiedSearchResults(cursor, hasMore, entries ?: emptyList()) + } + + private fun mapToMessage(unifiedSearchEntry: UnifiedSearchEntry, searchTerm: String): SearchMessageEntry { + val conversation = unifiedSearchEntry.attributes?.get(ATTRIBUTE_CONVERSATION)!! + val messageId = unifiedSearchEntry.attributes?.get(ATTRIBUTE_MESSAGE_ID) + return SearchMessageEntry( + searchTerm, + unifiedSearchEntry.thumbnailUrl, + unifiedSearchEntry.title!!, + unifiedSearchEntry.subline!!, + conversation, + messageId + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java index 2199191f6..9ca98882d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java @@ -32,10 +32,11 @@ import com.nextcloud.talk.models.RetrofitBucket; import com.nextcloud.talk.models.database.CapabilitiesUtil; import com.nextcloud.talk.models.database.UserEntity; +import org.jetbrains.annotations.NotNull; + import java.util.HashMap; import java.util.Map; -import androidx.annotation.DimenRes; import androidx.annotation.Nullable; import okhttp3.Credentials; @@ -456,4 +457,8 @@ public class ApiUtils { return baseUrl + ocsApiVersion + spreedApiVersion + "/reaction/" + roomToken + "/" + messageId; } + @NotNull + public static String getUrlForUnifiedSearch(@NotNull String baseUrl, @NotNull String providerId) { + return baseUrl + ocsApiVersion + "/search/providers/" + providerId + "/search"; + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt b/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt new file mode 100644 index 000000000..b0ef01bb5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk application + * + * @author Álvaro Brey + * Copyright (C) 2022 Álvaro Brey + * Copyright (C) 2022 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.utils + +import android.os.Handler +import android.os.Looper + +class Debouncer(var delay: Long) { + private val handler = Handler(Looper.getMainLooper()) + + fun debounce(runnable: Runnable) { + handler.removeCallbacksAndMessages(null) // clear handler + handler.postDelayed(runnable, delay) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java index 5b910ceed..b51b589e2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java @@ -36,6 +36,7 @@ import android.graphics.Typeface; import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; import android.graphics.drawable.VectorDrawable; import android.net.Uri; import android.os.Build; @@ -592,6 +593,30 @@ public class DisplayUtils { avatarImageView.setController(draweeController); } + public static void loadAvatarPlaceholder(final SimpleDraweeView targetView) { + final Context context = targetView.getContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Drawable[] layers = new Drawable[2]; + layers[0] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_background); + layers[1] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_foreground); + LayerDrawable layerDrawable = new LayerDrawable(layers); + + targetView.getHierarchy().setPlaceholderImage( + DisplayUtils.getRoundedDrawable(layerDrawable)); + } else { + targetView.getHierarchy().setPlaceholderImage(R.mipmap.ic_launcher); + } + } + + public static void loadImage(final SimpleDraweeView targetView, final ImageRequest imageRequest) { + final DraweeController newController = Fresco.newDraweeControllerBuilder() + .setOldController(targetView.getController()) + .setAutoPlayAnimations(true) + .setImageRequest(imageRequest) + .build(); + targetView.setController(newController); + } + public static @StringRes int getSortOrderStringId(FileSortOrder sortOrder) { switch (sortOrder.name) { diff --git a/app/src/main/res/layout/rv_item_load_more.xml b/app/src/main/res/layout/rv_item_load_more.xml new file mode 100644 index 000000000..d045da70b --- /dev/null +++ b/app/src/main/res/layout/rv_item_load_more.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/rv_item_search_message.xml b/app/src/main/res/layout/rv_item_search_message.xml new file mode 100644 index 000000000..a8ec4aaaf --- /dev/null +++ b/app/src/main/res/layout/rv_item_search_message.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83b3cceb4..4beee18ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -523,5 +523,7 @@ All Send without notification Call without notification + Messages + Load more results